Compare commits

..

1 Commits

Author SHA1 Message Date
yyh
6bf8758807 refactor: refine dify-ui switch contract 2026-05-23 13:30:11 +08:00
61 changed files with 1114 additions and 713 deletions

View File

@ -1770,6 +1770,14 @@
"count": 1
}
},
"web/app/components/base/textarea/index.stories.tsx": {
"no-console": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/voice-input/__tests__/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 3

View File

@ -33,7 +33,6 @@ 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 { Textarea } from '@langgenius/dify-ui/textarea'
import '@langgenius/dify-ui/styles.css' // once, in the app root
```
@ -41,16 +40,16 @@ 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`, `./textarea`, `./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 | `./tabs`, `./toggle-group` | 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. |
| 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 | `./tabs`, `./toggle-group` | 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. |
Utilities:
@ -65,7 +64,7 @@ Use `Form` for the submit boundary. It renders a native `<form>`, preserves Ente
Use `FieldRoot` for each standalone named field. A field must have a stable `name`, a label relationship, and either a `FieldControl` or another control that participates in the same Base UI field context. Prefer a visible label for normal form rows; when the surrounding UI already supplies the visible text, use the matching label primitive visually hidden or put `aria-label` on the actual interactive control. `FieldDescription` and `FieldError` provide the message relationships that screen readers need, while the Dify wrapper adds the default Form Input Set styling from the design system.
Choose the label primitive by the control semantics. Text-like inputs, `Textarea`, input-based `Combobox` / `Autocomplete`, single `Checkbox` / `Radio`, `Switch`, and `NumberField` use `FieldLabel`. Trigger-based `Select` fields use `SelectLabel`; `Slider` fields use `SliderLabel`, with per-thumb `aria-label` only when the thumbs need distinct names. `SelectGroupLabel` and `AutocompleteGroupLabel` only label grouped options inside their popup content; they are not field labels.
Choose the label primitive by the control semantics. Text-like inputs, input-based `Combobox` / `Autocomplete`, single `Checkbox` / `Radio`, `Switch`, and `NumberField` use `FieldLabel`. Trigger-based `Select` fields use `SelectLabel`; `Slider` fields use `SliderLabel`, with per-thumb `aria-label` only when the thumbs need distinct names. `SelectGroupLabel` and `AutocompleteGroupLabel` only label grouped options inside their popup content; they are not field labels.
Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group of related controls, such as checkbox groups, radio groups, multi-thumb sliders, or a section that combines several inputs. For checkbox and radio groups, wrap each option with `FieldItem` and give each option its own label:

View File

@ -113,10 +113,6 @@
"types": "./src/tabs/index.tsx",
"import": "./src/tabs/index.tsx"
},
"./textarea": {
"types": "./src/textarea/index.tsx",
"import": "./src/textarea/index.tsx"
},
"./toggle-group": {
"types": "./src/toggle-group/index.tsx",
"import": "./src/toggle-group/index.tsx"

View File

@ -49,6 +49,19 @@ describe('Switch', () => {
await expect.element(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
})
it('should work in uncontrolled mode with defaultChecked prop', async () => {
const onCheckedChange = vi.fn()
const screen = await render(<Switch defaultChecked={false} onCheckedChange={onCheckedChange} />)
const switchElement = screen.getByRole('switch')
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
asHTMLElement(switchElement.element()).click()
expect(onCheckedChange).toHaveBeenCalledWith(true)
await expect.element(switchElement).toHaveAttribute('aria-checked', 'true')
})
it('should not call onCheckedChange when disabled', async () => {
const onCheckedChange = vi.fn()
const screen = await render(<Switch checked={false} disabled onCheckedChange={onCheckedChange} />)
@ -142,6 +155,24 @@ describe('Switch', () => {
expect(screen.container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
})
it('should use checked data attributes to position spinner', async () => {
const screen = await render(<Switch checked={false} loading size="md" />)
const spinner = screen.container.querySelector('span[aria-hidden="true"]')
expect(spinner).toHaveClass(
'left-[calc(50%+6px)]',
'group-data-checked:left-[calc(50%-6px)]',
)
await screen.rerender(<Switch checked={true} loading size="md" />)
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-checked', '')
expect(screen.container.querySelector('span[aria-hidden="true"]')).toHaveClass(
'left-[calc(50%+6px)]',
'group-data-checked:left-[calc(50%-6px)]',
)
})
it('should not show spinner for xs and sm sizes', async () => {
const screen = await render(<Switch checked={false} loading size="xs" />)
expect(screen.container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()

View File

@ -2,6 +2,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ComponentProps } from 'react'
import { useState, useTransition } from 'react'
import { Switch, SwitchSkeleton } from '.'
import {
FieldDescription,
FieldLabel,
FieldRoot,
} from '../field'
const meta = {
title: 'Base/Form/Switch',
@ -10,7 +15,7 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'Toggle switch built on Base UI with CVA variants, Figma-aligned design tokens, loading spinner, and skeleton placeholder. Import `Switch` and `SwitchSkeleton` from `@langgenius/dify-ui/switch`.',
component: 'Toggle switch primitive with controlled and uncontrolled state support, loading state, and skeleton placeholder.',
},
},
},
@ -42,20 +47,27 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
const SwitchDemo = (args: Partial<ComponentProps<typeof Switch>>) => {
type SwitchDemoProps = Partial<Omit<ComponentProps<typeof Switch>, 'checked' | 'defaultChecked' | 'onCheckedChange'>> & {
checked?: boolean
}
const SwitchDemo = (args: SwitchDemoProps) => {
const [enabled, setEnabled] = useState(args.checked ?? false)
return (
<div className="flex items-center justify-center gap-3">
<Switch
{...args}
checked={enabled}
onCheckedChange={setEnabled}
/>
<span className="text-sm text-gray-700">
{enabled ? 'On' : 'Off'}
</span>
</div>
<FieldRoot name="autoRetry" className="w-72">
<FieldLabel className="flex items-center justify-between gap-3">
<span>Enable auto retry</span>
<Switch
{...args}
checked={enabled}
onCheckedChange={setEnabled}
/>
</FieldLabel>
<FieldDescription>
{enabled ? 'Failures will retry automatically.' : 'Failures require manual retry.'}
</FieldDescription>
</FieldRoot>
)
}
@ -116,24 +128,24 @@ const AllStatesDemo = () => {
<td className="py-3 font-medium text-gray-900">{size}</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} onCheckedChange={() => {}} />
<Switch size={size} checked={true} onCheckedChange={() => {}} />
<Switch size={size} checked={false} onCheckedChange={() => {}} aria-label={`${size} unchecked switch`} />
<Switch size={size} checked={true} onCheckedChange={() => {}} aria-label={`${size} checked switch`} />
</div>
</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} disabled />
<Switch size={size} checked={true} disabled />
<Switch size={size} checked={false} disabled aria-label={`${size} disabled unchecked switch`} />
<Switch size={size} checked={true} disabled aria-label={`${size} disabled checked switch`} />
</div>
</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} loading />
<Switch size={size} checked={true} loading />
<Switch size={size} checked={false} loading aria-label={`${size} loading unchecked switch`} />
<Switch size={size} checked={true} loading aria-label={`${size} loading checked switch`} />
</div>
</td>
<td className="py-3">
<SwitchSkeleton size={size} />
<SwitchSkeleton size={size} aria-hidden="true" />
</td>
</tr>
))}
@ -148,7 +160,7 @@ export const AllStates: Story = {
parameters: {
docs: {
description: {
story: 'Complete variant matrix: all sizes × all states, matching Figma design spec (node 2144:1210).',
story: 'Variant matrix for switch sizes and states.',
},
},
},
@ -164,22 +176,30 @@ const SizeComparisonDemo = () => {
return (
<div className="flex flex-col items-center space-y-4">
<div className="flex items-center gap-3">
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
<span className="text-sm text-gray-700">Extra Small (xs) 14×10</span>
</div>
<div className="flex items-center gap-3">
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
<span className="text-sm text-gray-700">Small (sm) 20×12</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
<span className="text-sm text-gray-700">Regular (md) 28×16</span>
</div>
<div className="flex items-center gap-3">
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
<span className="text-sm text-gray-700">Large (lg) 36×20</span>
</div>
<FieldRoot name="extraSmallSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
Extra Small (xs) - 14x10
</FieldLabel>
</FieldRoot>
<FieldRoot name="smallSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
Small (sm) - 20x12
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
Regular (md) - 28x16
</FieldLabel>
</FieldRoot>
<FieldRoot name="largeSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
Large (lg) - 36x20
</FieldLabel>
</FieldRoot>
</div>
)
}
@ -200,30 +220,42 @@ const LoadingDemo = () => {
{loading ? 'Stop Loading' : 'Start Loading'}
</button>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch size="lg" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Large unchecked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="lg" checked={true} loading={loading} />
<span className="text-sm text-gray-700">Large checked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Regular unchecked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={true} loading={loading} />
<span className="text-sm text-gray-700">Regular checked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="sm" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Small (no spinner)</span>
</div>
<div className="flex items-center gap-3">
<Switch size="xs" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Extra Small (no spinner)</span>
</div>
<FieldRoot name="largeUncheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={false} loading={loading} />
Large unchecked
</FieldLabel>
</FieldRoot>
<FieldRoot name="largeCheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={true} loading={loading} />
Large checked
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularUncheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={false} loading={loading} />
Regular unchecked
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularCheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={true} loading={loading} />
Regular checked
</FieldLabel>
</FieldRoot>
<FieldRoot name="smallLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="sm" checked={false} loading={loading} />
Small
</FieldLabel>
</FieldRoot>
<FieldRoot name="extraSmallLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="xs" checked={false} loading={loading} />
Extra Small
</FieldLabel>
</FieldRoot>
</div>
</div>
)
@ -234,7 +266,7 @@ export const Loading: Story = {
parameters: {
docs: {
description: {
story: 'Loading state disables interaction and shows a spinning icon (i-ri-loader-2-line) for md/lg sizes. Spinner position mirrors the knob: appears on the opposite side of the checked state.',
story: 'Loading state disables interaction and shows a spinner for md and lg sizes.',
},
},
},
@ -242,61 +274,76 @@ export const Loading: Story = {
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const MutationLoadingDemo = () => {
function useMockAutoRetrySettingQuery() {
const [enabled, setEnabled] = useState(false)
return {
data: {
enabled,
},
setData: setEnabled,
}
}
function useMockUpdateAutoRetrySettingMutation({
onSuccess,
}: {
onSuccess: (enabled: boolean) => void
}) {
const [requestCount, setRequestCount] = useState(0)
const [isPending, startTransition] = useTransition()
const handleChange = (nextValue: boolean) => {
const mutate = (nextValue: boolean) => {
if (isPending)
return
startTransition(async () => {
setRequestCount(current => current + 1)
await wait(1200)
setEnabled(nextValue)
onSuccess(nextValue)
})
}
return {
requestCount,
isPending,
mutate,
}
}
const MutationLoadingDemo = () => {
const autoRetrySetting = useMockAutoRetrySettingQuery()
const updateAutoRetrySetting = useMockUpdateAutoRetrySettingMutation({
onSuccess: autoRetrySetting.setData,
})
const statusText = updateAutoRetrySetting.isPending
? 'Saving changes...'
: autoRetrySetting.data.enabled
? 'Auto retry is enabled.'
: 'Auto retry is disabled.'
return (
<div className="w-[340px] space-y-4 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
<div className="space-y-1">
<p className="text-sm font-medium text-text-primary">Mutation Loading Guard</p>
<p className="text-xs text-text-tertiary">
Click once to start a simulated mutate call. While the request is pending, the switch enters
{' '}
<code className="rounded-sm bg-state-base-hover px-1 py-0.5 text-[11px]">loading</code>
{' '}
and rejects duplicate clicks.
</p>
</div>
<div className="grid w-90 gap-3 rounded-lg border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
<FieldRoot name="autoRetry">
<FieldLabel className="flex items-center justify-between gap-4">
<span className="system-sm-medium text-text-secondary">Enable auto retry</span>
<Switch
size="lg"
checked={autoRetrySetting.data.enabled}
loading={updateAutoRetrySetting.isPending}
onCheckedChange={updateAutoRetrySetting.mutate}
/>
</FieldLabel>
<FieldDescription>Retry failed workflow runs without manual intervention.</FieldDescription>
</FieldRoot>
<div className="flex items-center justify-between rounded-xl border border-components-panel-border-subtle bg-background-default-dodge px-3 py-2 shadow-sm">
<div className="space-y-1">
<p className="text-sm font-medium text-text-primary">Enable Auto Retry</p>
<p className="text-xs text-text-tertiary">
{isPending ? 'Saving…' : enabled ? 'Saved as on' : 'Saved as off'}
</p>
</div>
<Switch
size="lg"
checked={enabled}
loading={isPending}
onCheckedChange={handleChange}
aria-label="Enable Auto Retry"
/>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-text-tertiary">
<div className="rounded-lg bg-state-base-hover px-3 py-2">
<div className="font-medium text-text-secondary">Committed Value</div>
<div>{enabled ? 'On' : 'Off'}</div>
</div>
<div className="rounded-lg bg-state-base-hover px-3 py-2">
<div className="font-medium text-text-secondary">Mutate Count</div>
<div>{requestCount}</div>
</div>
</div>
<span className="text-xs text-text-tertiary" aria-live="polite">
{statusText}
{' '}
Save attempts:
{' '}
{updateAutoRetrySetting.requestCount}
</span>
</div>
)
}
@ -306,7 +353,7 @@ export const MutationLoadingGuard: Story = {
parameters: {
docs: {
description: {
story: 'Simulates a controlled switch backed by an async mutate call. The component keeps its previous committed value, sets `loading` during the request, and blocks duplicate clicks until the mutation resolves.',
story: 'Controlled switch that enters loading while the change is saved.',
},
},
},
@ -315,19 +362,19 @@ export const MutationLoadingGuard: Story = {
const SkeletonDemo = () => (
<div className="flex flex-col items-center space-y-4">
<div className="flex items-center gap-3">
<SwitchSkeleton size="xs" />
<SwitchSkeleton size="xs" aria-hidden="true" />
<span className="text-sm text-gray-700">Extra Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="sm" />
<SwitchSkeleton size="sm" aria-hidden="true" />
<span className="text-sm text-gray-700">Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="md" />
<SwitchSkeleton size="md" aria-hidden="true" />
<span className="text-sm text-gray-700">Regular skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="lg" />
<SwitchSkeleton size="lg" aria-hidden="true" />
<span className="text-sm text-gray-700">Large skeleton</span>
</div>
</div>
@ -338,7 +385,7 @@ export const Skeleton: Story = {
parameters: {
docs: {
description: {
story: '`SwitchSkeleton` renders a non-interactive placeholder with `bg-text-quaternary opacity-20`. Exported from `@langgenius/dify-ui/switch` alongside `Switch`.',
story: 'Non-interactive placeholders for switch loading layouts.',
},
},
},

View File

@ -45,26 +45,34 @@ const switchThumbVariants = cva(
export type SwitchSize = NonNullable<VariantProps<typeof switchRootVariants>['size']>
const spinnerSizeConfig: Partial<Record<SwitchSize, {
icon: string
uncheckedPosition: string
checkedPosition: string
}>> = {
md: {
icon: 'size-2',
uncheckedPosition: 'left-[calc(50%+6px)]',
checkedPosition: 'left-[calc(50%-6px)]',
},
lg: {
icon: 'size-2.5',
uncheckedPosition: 'left-[calc(50%+8px)]',
checkedPosition: 'left-[calc(50%-8px)]',
const switchSpinnerVariants = cva(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
{
variants: {
size: {
md: 'size-2 left-[calc(50%+6px)] group-data-checked:left-[calc(50%-6px)]',
lg: 'size-2.5 left-[calc(50%+8px)] group-data-checked:left-[calc(50%-8px)]',
},
},
},
)
type ControlledSwitchProps = {
checked: boolean
defaultChecked?: never
}
type UncontrolledSwitchProps = {
checked?: never
defaultChecked?: boolean
}
type SwitchControlProps = ControlledSwitchProps | UncontrolledSwitchProps
export type SwitchProps
= Omit<BaseSwitchNS.Root.Props, 'className' | 'size' | 'onCheckedChange'>
= Omit<BaseSwitchNS.Root.Props, 'checked' | 'defaultChecked' | 'className' | 'size' | 'onCheckedChange'>
& VariantProps<typeof switchRootVariants>
& SwitchControlProps
& {
onCheckedChange?: (checked: boolean) => void
loading?: boolean
@ -81,7 +89,6 @@ export function Switch({
...props
}: SwitchProps) {
const isDisabled = disabled || loading
const spinner = loading && size ? spinnerSizeConfig[size] : undefined
return (
<BaseSwitch.Root
@ -95,14 +102,10 @@ export function Switch({
<BaseSwitch.Thumb
className={switchThumbVariants({ size })}
/>
{spinner
{loading && (size === 'md' || size === 'lg')
? (
<span
className={cn(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
spinner.icon,
checked ? spinner.checkedPosition : spinner.uncheckedPosition,
)}
className={switchSpinnerVariants({ size })}
aria-hidden="true"
>
<i className="i-ri-loader-2-line size-full animate-spin text-text-tertiary motion-reduce:animate-none" />
@ -131,11 +134,8 @@ const switchSkeletonVariants = cva(
)
export type SwitchSkeletonProps
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
= HTMLAttributes<HTMLDivElement>
& VariantProps<typeof switchSkeletonVariants>
& {
className?: string
}
export function SwitchSkeleton({
size = 'md',

View File

@ -1,133 +0,0 @@
import { render } from 'vitest-browser-react'
import {
FieldDescription,
FieldError,
FieldLabel,
FieldRoot,
} from '../../field'
import { Form } from '../../form'
import { Textarea } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
const setTextareaValue = (element: HTMLElement | SVGElement, value: string) => {
const textarea = asHTMLElement(element) as HTMLTextAreaElement
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
valueSetter?.call(textarea, value)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('Textarea', () => {
it('should render a labelled textarea through Base UI Field.Control', async () => {
const screen = await render(
<FieldRoot name="description">
<FieldLabel>Description</FieldLabel>
<Textarea defaultValue="A workspace for support automation." />
<FieldDescription>Shown to workspace members.</FieldDescription>
</FieldRoot>,
)
const textarea = screen.getByRole('textbox', { name: 'Description' })
await expect.element(textarea).toHaveValue('A workspace for support automation.')
await expect.element(textarea).toHaveAccessibleDescription('Shown to workspace members.')
await expect.element(textarea).toHaveClass('min-h-20', 'rounded-lg', 'system-sm-regular')
expect(asHTMLElement(textarea.element()).tagName).toBe('TEXTAREA')
})
it('should apply size variants and custom classes', async () => {
const screen = await render(
<label>
Prompt
<Textarea size="large" className="resize-none" />
</label>,
)
await expect.element(screen.getByRole('textbox', { name: 'Prompt' })).toHaveClass(
'rounded-[10px]',
'px-4',
'py-2',
'system-md-regular',
'resize-none',
)
})
it('should call onValueChange and stay controlled until value changes', async () => {
const onValueChange = vi.fn()
const screen = await render(
<label>
Notes
<Textarea value="" onValueChange={onValueChange} />
</label>,
)
const textarea = screen.getByRole('textbox', { name: 'Notes' })
setTextareaValue(textarea.element(), 'a')
expect(onValueChange).toHaveBeenCalledWith('a', expect.any(Object))
await expect.element(textarea).toHaveValue('')
await screen.rerender(
<label>
Notes
<Textarea value="a" onValueChange={onValueChange} />
</label>,
)
await expect.element(screen.getByRole('textbox', { name: 'Notes' })).toHaveValue('a')
})
it('should submit valid values and show validation errors through Base UI Form', async () => {
const onFormSubmit = vi.fn()
const screen = await render(
<Form aria-label="dataset form" onFormSubmit={onFormSubmit}>
<FieldRoot name="summary">
<FieldLabel>Summary</FieldLabel>
<Textarea required minLength={10} />
<FieldError match="valueMissing">Summary is required.</FieldError>
<FieldError match="tooShort">Summary is too short.</FieldError>
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
const saveButton = asHTMLElement(screen.getByRole('button', { name: 'Save' }).element())
saveButton.click()
await vi.waitFor(async () => {
await expect.element(screen.getByText('Summary is required.')).toBeInTheDocument()
await expect.element(screen.getByRole('textbox', { name: 'Summary' })).toHaveAttribute('aria-invalid', 'true')
})
expect(onFormSubmit).not.toHaveBeenCalled()
await screen.rerender(
<Form aria-label="dataset form" onFormSubmit={onFormSubmit}>
<FieldRoot name="summary">
<FieldLabel>Summary</FieldLabel>
<Textarea key="valid-summary" required minLength={10} defaultValue="Long enough summary" />
<FieldError match="valueMissing">Summary is required.</FieldError>
<FieldError match="tooShort">Summary is too short.</FieldError>
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
expect(onFormSubmit).toHaveBeenCalledTimes(1)
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({ summary: 'Long enough summary' })
})
it('should render character count when maxLength is set', async () => {
const screen = await render(
<label>
Release notes
<Textarea defaultValue="Draft" maxLength={20} />
</label>,
)
await expect.element(screen.getByText('5/20')).toBeInTheDocument()
const textarea = screen.getByRole('textbox', { name: 'Release notes' })
await expect.element(textarea).toHaveClass('pb-7')
setTextareaValue(textarea.element(), 'Published')
await expect.element(screen.getByText('9/20')).toBeInTheDocument()
})
})

View File

@ -1,141 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import { Button } from '../button'
import {
FieldDescription,
FieldError,
FieldLabel,
FieldRoot,
} from '../field'
import { Form } from '../form'
import { Textarea } from './index'
const meta = {
title: 'Base/Form/Textarea',
component: Textarea,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Multiline text control built on Base UI Field.Control. Use it with FieldRoot for labelled, described, and validated form fields.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Textarea>
export default meta
type Story = StoryObj<typeof meta>
export const Basic: Story = {
render: () => (
<div className="w-80">
<label htmlFor="workspace-description" className="mb-1 block w-fit py-1 text-text-secondary system-sm-medium">
Workspace description
</label>
<Textarea
id="workspace-description"
name="workspaceDescription"
placeholder="Describe how this workspace is used..."
/>
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="grid w-80 gap-3">
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="small-textarea">
Small
<Textarea id="small-textarea" size="small" name="smallTextarea" placeholder="Short note..." rows={3} />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="medium-textarea">
Medium
<Textarea id="medium-textarea" name="mediumTextarea" placeholder="Add context..." rows={3} />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="large-textarea">
Large
<Textarea id="large-textarea" size="large" name="largeTextarea" placeholder="Write a longer instruction..." rows={3} />
</label>
</div>
),
}
export const States: Story = {
render: () => (
<div className="grid w-80 gap-3">
<FieldRoot name="placeholderState">
<FieldLabel>Placeholder</FieldLabel>
<Textarea placeholder="Add a description..." rows={3} />
</FieldRoot>
<FieldRoot name="filledState">
<FieldLabel>Filled</FieldLabel>
<Textarea defaultValue="Use this dataset for support articles and product FAQs." rows={3} />
</FieldRoot>
<FieldRoot name="invalidState" invalid>
<FieldLabel>Invalid</FieldLabel>
<Textarea defaultValue="Too short" rows={3} />
<FieldError match>Use at least 20 characters.</FieldError>
</FieldRoot>
<FieldRoot name="disabledState">
<FieldLabel>Disabled</FieldLabel>
<Textarea disabled placeholder="Editing is unavailable..." rows={3} />
</FieldRoot>
<FieldRoot name="readonlyState">
<FieldLabel>Read-only</FieldLabel>
<Textarea readOnly defaultValue="Generated from the published workflow configuration." rows={3} />
</FieldRoot>
</div>
),
}
export const WithField: Story = {
render: () => (
<Form aria-label="Dataset settings" className="grid w-80 gap-4" onFormSubmit={() => undefined}>
<FieldRoot name="description">
<FieldLabel>Description</FieldLabel>
<Textarea
required
minLength={20}
maxLength={160}
placeholder="Describe what this dataset contains..."
rows={4}
className="resize-y"
/>
<FieldDescription>Shown to teammates when they choose a knowledge source.</FieldDescription>
<FieldError match="valueMissing">Description is required.</FieldError>
<FieldError match="tooShort">Use at least 20 characters.</FieldError>
</FieldRoot>
<div className="flex justify-end">
<Button type="submit" variant="primary">Save Settings</Button>
</div>
</Form>
),
}
const ControlledDemo = () => {
const [value, setValue] = useState('Summarize customer feedback into actionable product themes.')
return (
<FieldRoot name="prompt">
<FieldLabel>Prompt</FieldLabel>
<Textarea
value={value}
onValueChange={setValue}
maxLength={120}
rows={4}
className="resize-y"
/>
<FieldDescription>The saved value is updated from the controlled state.</FieldDescription>
</FieldRoot>
)
}
export const Controlled: Story = {
render: () => (
<div className="w-80">
<ControlledDemo />
</div>
),
}

View File

@ -1,115 +0,0 @@
'use client'
import type { Field as BaseFieldNS } from '@base-ui/react/field'
import type { VariantProps } from 'class-variance-authority'
import type { ComponentPropsWithRef } from 'react'
import { Field as BaseField } from '@base-ui/react/field'
import { cva } from 'class-variance-authority'
import { useState } from 'react'
import { cn } from '../cn'
const textareaVariants = cva(
[
'min-h-20 w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
'placeholder:text-components-input-text-placeholder',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'rounded-md px-2 py-1 system-xs-regular',
medium: 'rounded-lg px-3 py-2 system-sm-regular',
large: 'rounded-[10px] px-4 py-2 system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
type TextareaValue = string | number
export type TextareaSize = NonNullable<VariantProps<typeof textareaVariants>['size']>
export type TextareaChangeEventDetails = BaseFieldNS.Control.ChangeEventDetails
type TextareaOnValueChange = (value: string, eventDetails: TextareaChangeEventDetails) => void
type ControlledTextareaProps = {
value: TextareaValue
defaultValue?: never
onValueChange: TextareaOnValueChange
}
type UncontrolledTextareaProps = {
value?: never
defaultValue?: TextareaValue
onValueChange?: TextareaOnValueChange
}
type NativeTextareaProps = Omit<
ComponentPropsWithRef<'textarea'>,
'children' | 'className' | 'defaultValue' | 'onChange' | 'size' | 'value'
>
type TextareaControlProps = ControlledTextareaProps | UncontrolledTextareaProps
type TextareaVariantProps = VariantProps<typeof textareaVariants>
export type TextareaProps
= NativeTextareaProps
& TextareaControlProps
& TextareaVariantProps
& {
children?: never
className?: string
}
function getTextareaValueLength(value: TextareaValue | undefined) {
return String(value ?? '').length
}
export function Textarea({
className,
defaultValue,
maxLength,
onValueChange,
ref,
size = 'medium',
value,
...props
}: TextareaProps) {
const showCharacterCount = maxLength !== undefined
const [uncontrolledValueLength, setUncontrolledValueLength] = useState(() => getTextareaValueLength(defaultValue))
const valueLength = value === undefined ? uncontrolledValueLength : getTextareaValueLength(value)
return (
<div className="relative w-full">
<BaseField.Control
className={cn(textareaVariants({ size }), showCharacterCount && 'pb-7', className)}
defaultValue={defaultValue}
maxLength={maxLength}
onValueChange={(nextValue, eventDetails) => {
if (showCharacterCount && value === undefined)
setUncontrolledValueLength(nextValue.length)
onValueChange?.(nextValue, eventDetails)
}}
render={<textarea {...props} ref={ref} />}
value={value}
/>
{showCharacterCount
? (
<span className="pointer-events-none absolute right-2 bottom-2 rounded-sm bg-components-panel-bg px-1 py-0.5 text-text-tertiary system-2xs-medium">
{valueLength}
/
{maxLength}
</span>
)
: null}
</div>
)
}

View File

@ -1,10 +1,10 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import { useAppContext } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
@ -63,12 +63,11 @@ export default function FeedBack(props: DeleteAccountProps) {
</DialogTitle>
<label className="mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary">{t('account.feedbackLabel', { ns: 'common' })}</label>
<Textarea
aria-label={t('account.feedbackLabel', { ns: 'common' }) as string}
rows={6}
value={userFeedback}
placeholder={t('account.feedbackPlaceholder', { ns: 'common' }) as string}
onValueChange={(value) => {
setUserFeedback(value)
onChange={(e) => {
setUserFeedback(e.target.value)
}}
/>
<div className="mt-3 flex w-full flex-col gap-2">

View File

@ -1,9 +1,9 @@
'use client'
import type { FC } from 'react'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
import Textarea from '@/app/components/base/textarea'
export enum EditItemType {
Query = 'query',
@ -33,9 +33,8 @@ const EditItem: FC<Props> = ({
<div className="grow">
<div className="mb-1 system-xs-semibold text-text-primary">{name}</div>
<Textarea
aria-label={name}
value={content}
onValueChange={value => onChange(value)}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
placeholder={placeholder}
autoFocus
/>

View File

@ -2,12 +2,12 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
import Textarea from '@/app/components/base/textarea'
export enum EditItemType {
Query = 'query',
@ -130,9 +130,8 @@ const EditItem: FC<Props> = ({
<div className="mt-3">
<EditTitle title={editTitle} />
<Textarea
aria-label={editTitle}
value={newContent}
onValueChange={value => setNewContent(value)}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewContent(e.target.value)}
placeholder={placeholder}
autoFocus
/>

View File

@ -3,12 +3,12 @@ import type { VersionHistory } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '../../base/textarea'
type VersionInfoModalProps = {
isOpen: boolean
@ -57,8 +57,8 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
onClose()
}
const handleDescriptionChange = useCallback((value: string) => {
setReleaseNotes(value)
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setReleaseNotes(e.target.value)
}, [])
return (
@ -95,16 +95,17 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
onValueChange={setTitle}
/>
</FieldRoot>
<FieldRoot name="releaseNotes" invalid={releaseNotesError} className="gap-y-1">
<FieldLabel className="flex h-6 items-center py-0 system-sm-semibold text-text-secondary">
<div className="flex flex-col gap-y-1">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
{t('versionHistory.editField.releaseNotes', { ns: 'workflow' })}
</FieldLabel>
</div>
<Textarea
value={releaseNotes}
placeholder={`${t('versionHistory.releaseNotesPlaceholder', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`}
onValueChange={handleDescriptionChange}
onChange={handleDescriptionChange}
destructive={releaseNotesError}
/>
</FieldRoot>
</div>
</div>
<div className="flex justify-end p-6 pt-5">
<div className="flex items-center gap-x-3">

View File

@ -13,12 +13,12 @@ import {
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { Trans } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -121,9 +121,8 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
{type === InputVarType.paragraph && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Textarea
aria-label={t('variableConfig.defaultValue', { ns: 'appDebug' })}
value={String(tempPayload.default ?? '')}
onValueChange={value => onPayloadChange('default')(value || undefined)}
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>

View File

@ -1,11 +1,11 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import Textarea from '@/app/components/base/textarea'
const i18nPrefix = 'generate'
@ -40,11 +40,10 @@ const IdeaOutput: FC<Props> = ({
</div>
{!isFoldIdeaOutput && (
<Textarea
aria-label={t(`${i18nPrefix}.idealOutput`, { ns: 'appDebug' })}
className="h-[80px]"
placeholder={t(`${i18nPrefix}.idealOutputPlaceholder`, { ns: 'appDebug' })}
value={value}
onValueChange={value => onChange(value)}
onChange={e => onChange(e.target.value)}
/>
)}
</div>

View File

@ -4,13 +4,13 @@ import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { isEqual } from 'es-toolkit/predicate'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import IndexMethod from '@/app/components/datasets/settings/index-method'
@ -224,9 +224,8 @@ const SettingsModal: FC<SettingsModalProps> = ({
</div>
<div className="w-full">
<Textarea
aria-label={t('form.desc', { ns: 'datasetSettings' })}
value={localeCurrentDataset.description || ''}
onValueChange={value => handleValueChange('description', value)}
onChange={e => handleValueChange('description', e.target.value)}
className="resize-none"
placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''}
/>

View File

@ -84,6 +84,25 @@ vi.mock('@langgenius/dify-ui/select', async () => {
}
})
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, placeholder, readOnly, className }: {
value: string
onChange: (e: { target: { value: string } }) => void
placeholder?: string
readOnly?: boolean
className?: string
}) => (
<textarea
data-testid={`textarea-${placeholder}`}
value={value}
onChange={onChange}
placeholder={placeholder}
readOnly={readOnly}
className={className}
/>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({
default: ({ name, value, required, onChange, readonly }: {
name: string
@ -204,7 +223,7 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{}} />)
expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument()
expect(screen.getByTestId('textarea-Description')).toBeInTheDocument()
})
it('should render select input type', () => {
@ -256,7 +275,7 @@ describe('ChatUserInput', () => {
render(<ChatUserInput inputs={{}} />)
expect(screen.getByTestId('input-Name')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument()
expect(screen.getByTestId('textarea-Description')).toBeInTheDocument()
expect(screen.getByTestId('select-input')).toBeInTheDocument()
})
@ -315,7 +334,7 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{ desc: 'Long text here' }} />)
expect(screen.getByRole('textbox', { name: 'Description' })).toHaveValue('Long text here')
expect(screen.getByTestId('textarea-Description')).toHaveValue('Long text here')
})
it('should display existing input values for number type', () => {
@ -399,7 +418,7 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{}} />)
fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { target: { value: 'New Description' } })
fireEvent.change(screen.getByTestId('textarea-Description'), { target: { value: 'New Description' } })
expect(mockSetInputs).toHaveBeenCalledWith({ desc: 'New Description' })
})
@ -507,7 +526,7 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{}} />)
expect(screen.getByRole('textbox', { name: 'Description' })).toHaveAttribute('readonly')
expect(screen.getByTestId('textarea-Description')).toHaveAttribute('readonly')
})
it('should disable select when readonly is true', () => {

View File

@ -1,12 +1,12 @@
import type { Inputs } from '@/models/debug'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import ConfigContext from '@/context/debug-configuration'
@ -94,10 +94,9 @@ const ChatUserInput = ({
{type === 'paragraph' && (
<Textarea
className="h-[120px] grow"
aria-label={name || key}
placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''}
onValueChange={(value) => { handleInputValueChange(key, value) }}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
readOnly={readonly}
/>
)}

View File

@ -5,7 +5,6 @@ import type { VisionFile, VisionSettings } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiArrowDownSLine,
@ -20,6 +19,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum, ModelModeType } from '@/types/app'
@ -151,11 +151,10 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
)}
{type === 'paragraph' && (
<Textarea
aria-label={name}
className="h-[120px] grow"
placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''}
onValueChange={(value) => { handleInputValueChange(key, value) }}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
readOnly={readonly}
/>
)}

View File

@ -4,7 +4,6 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
@ -14,6 +13,7 @@ import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
@ -240,11 +240,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</span>
</div>
<Textarea
aria-label={t('newApp.captionDescription', { ns: 'app' })}
className="resize-none"
placeholder={t('newApp.appDescriptionPlaceholder', { ns: 'app' }) || ''}
value={description}
onValueChange={value => setDescription(value)}
onChange={e => setDescription(e.target.value)}
/>
</div>
</div>

View File

@ -8,7 +8,6 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
@ -19,6 +18,7 @@ import AppIconPicker from '@/app/components/base/app-icon-picker'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import { PremiumBadgeButton } from '@/app/components/base/premium-badge'
import Textarea from '@/app/components/base/textarea'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@ -289,10 +289,9 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<div className="relative">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</div>
<Textarea
aria-label={t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}
className="mt-1"
value={inputInfo.desc}
onValueChange={onDesChange}
onChange={e => onDesChange(e.target.value)}
placeholder={t(`${prefixSettings}.webDescPlaceholder`, { ns: 'appOverview' }) as string}
/>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</p>
@ -465,10 +464,9 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
<Textarea
aria-label={t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}
className="mt-1"
value={inputInfo.customDisclaimer}
onValueChange={value => setInputInfo(item => ({ ...item, customDisclaimer: value }))}
onChange={onChange('customDisclaimer')}
placeholder={t(`${prefixSettings}.more.customDisclaimerPlaceholder`, { ns: 'appOverview' }) as string}
/>
</div>

View File

@ -7,8 +7,8 @@ import {
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { InputVarType } from '@/app/components/workflow/types'
@ -72,10 +72,9 @@ const WorkflowHiddenInputFields = ({
) {
return (
<Textarea
aria-label={label}
id={fieldId}
value={typeof fieldValue === 'string' ? fieldValue : ''}
onValueChange={value => onValueChange(variable.variable, value)}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onValueChange(variable.variable, event.target.value)}
placeholder={label}
maxLength={variable.max_length}
className="min-h-24"

View File

@ -1,10 +1,10 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -71,9 +71,8 @@ const InputsFormContent = ({ showTip }: Props) => {
)}
{form.type === InputVarType.paragraph && (
<Textarea
aria-label={form.label}
value={inputsFormValue?.[form.variable] || ''}
onValueChange={value => handleFormChange(form.variable, value)}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
/>
)}

View File

@ -1,8 +1,8 @@
import type { ContentItemProps } from './type'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useMemo } from 'react'
import { Markdown } from '@/app/components/base/markdown'
import Textarea from '@/app/components/base/textarea'
const ContentItem = ({
content,
@ -42,10 +42,9 @@ const ContentItem = ({
<div className="py-3">
{formInputField.type === 'paragraph' && (
<Textarea
aria-label={fieldName}
className="h-[104px] sm:text-xs"
value={inputs[fieldName]!}
onValueChange={(value) => { onInputChange(fieldName, value) }}
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
data-testid="content-item-textarea"
/>
)}

View File

@ -6,7 +6,6 @@ import type {
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import copy from 'copy-to-clipboard'
@ -22,6 +21,7 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu
import Log from '@/app/components/base/chat/chat/log'
import AnnotationCtrlButton from '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button'
import NewAudioButton from '@/app/components/base/new-audio-button'
import Textarea from '@/app/components/base/textarea'
import { useChatContext } from '../context'
type OperationProps = {
@ -391,11 +391,10 @@ function Operation({
{t('feedback.content', { ns: 'common' }) || 'Feedback Content'}
</label>
<Textarea
aria-label={t('feedback.title', { ns: 'common' }) || 'Feedback'}
id={feedbackTextareaId}
name="feedback-content"
value={feedbackContent}
onValueChange={value => setFeedbackContent(value)}
onChange={e => setFeedbackContent(e.target.value)}
placeholder={t('feedback.placeholder', { ns: 'common' }) || 'Please describe what went wrong or how we can improve…'}
rows={4}
className="w-full"

View File

@ -1,10 +1,10 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -71,9 +71,8 @@ const InputsFormContent = ({ showTip }: Props) => {
)}
{form.type === InputVarType.paragraph && (
<Textarea
aria-label={form.label}
value={inputsFormValue?.[form.variable] || ''}
onValueChange={value => handleFormChange(form.variable, value)}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
/>
)}

View File

@ -12,10 +12,10 @@ import { FieldItem, FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { produce } from 'immer'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
@ -220,10 +220,9 @@ const FollowUpSettingModal = ({
</div>
{promptMode === PROMPT_MODE.custom && (
<Textarea
aria-label={t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOption', { ns: 'appDebug' })}
className="mt-3 min-h-32 resize-y border-components-input-border-active bg-components-input-bg-normal"
value={prompt}
onValueChange={value => setPrompt(value)}
onChange={e => setPrompt(e.target.value)}
maxLength={CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH}
placeholder={t('feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', { ns: 'appDebug' }) || ''}
/>

View File

@ -2,7 +2,7 @@ import type { FC } from 'react'
import type { CodeBasedExtensionForm } from '@/models/common'
import type { ModerationConfig } from '@/models/debug'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import Textarea from '@/app/components/base/textarea'
import { useLocale } from '@/context/i18n'
type FormGenerationProps = {
@ -55,11 +55,10 @@ const FormGeneration: FC<FormGenerationProps> = ({
form.type === 'paragraph' && (
<div className="relative">
<Textarea
aria-label={locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
className="resize-none"
value={value?.[form.variable] || ''}
placeholder={form.placeholder}
onValueChange={value => handleFormChange(form.variable, value)}
onChange={e => handleFormChange(form.variable, e.target.value)}
/>
</div>
)

View File

@ -1,16 +1,16 @@
import type { TextareaProps } from '@langgenius/dify-ui/textarea'
import type { TextareaProps } from '../../../textarea'
import type { LabelProps } from '../label'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useFieldContext } from '../..'
import Textarea from '../../../textarea'
import Label from '../label'
type TextAreaFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
} & Omit<TextareaProps, 'className' | 'defaultValue' | 'onBlur' | 'value' | 'id'>
} & Omit<TextareaProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'>
const TextAreaField = ({
label,
@ -28,10 +28,9 @@ const TextAreaField = ({
{...(labelOptions ?? {})}
/>
<Textarea
aria-label={label}
id={field.name}
value={field.state.value}
onValueChange={value => field.handleChange(value)}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
{...inputProps}
/>

View File

@ -3,7 +3,6 @@ import type { Dayjs } from 'dayjs'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useChatContext } from '@/app/components/base/chat/chat/context'
@ -11,6 +10,7 @@ import DatePicker from '@/app/components/base/date-and-time-picker/date-picker'
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-time-picker/utils/dayjs'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
const DATA_FORMAT = {
TEXT: 'text',
@ -372,12 +372,11 @@ const MarkdownForm = ({ node }: { node: HastElement }) => {
return null
return (
<Textarea
aria-label={name}
key={key}
name={name}
placeholder={str(child.properties.placeholder)}
value={str(formValues[name])}
onValueChange={value => updateValue(name, value)}
onChange={e => updateValue(name, e.target.value)}
/>
)
}

View File

@ -2,12 +2,12 @@
import type { FC } from 'react'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import { VarType } from '@/app/components/workflow/types'
import Textarea from '../../../textarea'
import TagLabel from './tag-label'
import TypeSwitch from './type-switch'
@ -72,7 +72,6 @@ const PrePopulate: FC<Props> = ({
value,
onValueChange,
}) => {
const { t } = useTranslation()
const [onPlaceholderClicked, setOnPlaceholderClicked] = useState(false)
const handleTypeChange = useCallback((isVar: boolean) => {
setOnPlaceholderClicked(true)
@ -128,10 +127,9 @@ const PrePopulate: FC<Props> = ({
return (
<div className={cn('relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal pb-1', isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs')}>
<Textarea
aria-label={t(`${i18nPrefix}.staticContent`, { ns: 'workflow' })}
value={value || ''}
className="h-[43px] min-h-[43px] rounded-none border-none bg-transparent px-3 hover:bg-transparent focus:bg-transparent focus:shadow-none"
onValueChange={value => onValueChange?.(value)}
onChange={e => onValueChange?.(e.target.value)}
onFocus={() => {
setOnPlaceholderClicked(true)
setIsFocus(true)

View File

@ -0,0 +1,77 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import TextArea from '../index'
describe('TextArea', () => {
it('should render correctly with default props', () => {
render(<TextArea value="" onChange={vi.fn()} />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveValue('')
})
it('should handle value and onChange correctly', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
const { rerender } = render(<TextArea value="initial" onChange={handleChange} />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toHaveValue('initial')
await user.type(textarea, ' updated')
expect(handleChange).toHaveBeenCalled()
rerender(<TextArea value="initial updated" onChange={handleChange} />)
expect(textarea).toHaveValue('initial updated')
})
it('should handle autoFocus correctly', () => {
render(<TextArea value="" onChange={vi.fn()} autoFocus />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toHaveFocus()
})
it('should handle disabled state', () => {
render(<TextArea value="" onChange={vi.fn()} disabled />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toBeDisabled()
expect(textarea).toHaveClass('cursor-not-allowed')
})
it('should handle placeholder', () => {
render(<TextArea value="" onChange={vi.fn()} placeholder="Enter text here" />)
expect(screen.getByPlaceholderText('Enter text here')).toBeInTheDocument()
})
it('should handle className', () => {
render(<TextArea value="" onChange={vi.fn()} className="custom-class" />)
expect(screen.getByTestId('text-area')).toHaveClass('custom-class')
})
it('should handle size variants', () => {
const { rerender } = render(<TextArea value="" onChange={vi.fn()} size="small" />)
expect(screen.getByTestId('text-area')).toHaveClass('py-1')
rerender(<TextArea value="" onChange={vi.fn()} size="large" />)
expect(screen.getByTestId('text-area')).toHaveClass('px-4')
})
it('should handle destructive state', () => {
render(<TextArea value="" onChange={vi.fn()} destructive />)
expect(screen.getByTestId('text-area')).toHaveClass('border-components-input-border-destructive')
})
it('should handle onFocus and onBlur', async () => {
const user = userEvent.setup()
const handleFocus = vi.fn()
const handleBlur = vi.fn()
render(<TextArea value="" onChange={vi.fn()} onFocus={handleFocus} onBlur={handleBlur} />)
const textarea = screen.getByTestId('text-area')
await user.click(textarea)
expect(handleFocus).toHaveBeenCalled()
await user.tab()
expect(handleBlur).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,562 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import Textarea from '.'
const meta = {
title: 'Base/Data Entry/Textarea',
component: Textarea,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Textarea component with multiple sizes (small, regular, large). Built with class-variance-authority for consistent styling.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['small', 'regular', 'large'],
description: 'Textarea size',
},
value: {
control: 'text',
description: 'Textarea value',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
destructive: {
control: 'boolean',
description: 'Error/destructive state',
},
rows: {
control: 'number',
description: 'Number of visible text rows',
},
},
} satisfies Meta<typeof Textarea>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const TextareaDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '500px' }}>
<Textarea
{...args}
value={value}
onChange={(e) => {
setValue(e.target.value)
console.log('Textarea changed:', e.target.value)
}}
/>
{value && (
<div className="mt-3 text-sm text-gray-600">
Character count:
{' '}
<span className="font-semibold">{value.length}</span>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
placeholder: 'Enter text...',
rows: 4,
value: '',
},
}
// Small size
export const SmallSize: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'small',
placeholder: 'Small textarea...',
rows: 3,
value: '',
},
}
// Large size
export const LargeSize: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'large',
placeholder: 'Large textarea...',
rows: 5,
value: '',
},
}
// With initial value
export const WithInitialValue: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
value: 'This is some initial text content.\n\nIt spans multiple lines.',
rows: 4,
},
}
// Disabled state
export const Disabled: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
value: 'This textarea is disabled and cannot be edited.',
disabled: true,
rows: 3,
},
}
// Destructive/error state
export const DestructiveState: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
value: 'This content has an error.',
destructive: true,
rows: 3,
},
}
// Size comparison
const SizeComparisonDemo = () => {
const [small, setSmall] = useState('')
const [regular, setRegular] = useState('')
const [large, setLarge] = useState('')
return (
<div style={{ width: '600px' }} className="space-y-4">
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Small</label>
<Textarea
size="small"
value={small}
onChange={e => setSmall(e.target.value)}
placeholder="Small textarea..."
rows={3}
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Regular</label>
<Textarea
size="regular"
value={regular}
onChange={e => setRegular(e.target.value)}
placeholder="Regular textarea..."
rows={4}
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Large</label>
<Textarea
size="large"
value={large}
onChange={e => setLarge(e.target.value)}
placeholder="Large textarea..."
rows={5}
/>
</div>
</div>
)
}
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// State comparison
const StateComparisonDemo = () => {
const [normal, setNormal] = useState('Normal state')
const [error, setError] = useState('Error state')
return (
<div style={{ width: '500px' }} className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Normal</label>
<Textarea
value={normal}
onChange={e => setNormal(e.target.value)}
rows={3}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Destructive</label>
<Textarea
value={error}
onChange={e => setError(e.target.value)}
destructive
rows={3}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Disabled</label>
<Textarea
value="Disabled state"
onChange={() => undefined}
disabled
rows={3}
/>
</div>
</div>
)
}
export const StateComparison: Story = {
render: () => <StateComparisonDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Comment form
const CommentFormDemo = () => {
const [comment, setComment] = useState('')
const maxLength = 500
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Leave a Comment</h3>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="Share your thoughts..."
rows={5}
maxLength={maxLength}
/>
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-gray-500">
{comment.length}
{' '}
/
{maxLength}
{' '}
characters
</span>
<button
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={comment.trim().length === 0}
>
Post Comment
</button>
</div>
</div>
)
}
export const CommentForm: Story = {
render: () => <CommentFormDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Feedback form
const FeedbackFormDemo = () => {
const [feedback, setFeedback] = useState('')
const [email, setEmail] = useState('')
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold">Send Feedback</h3>
<p className="mb-4 text-sm text-gray-600">Help us improve our product</p>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Your Email</label>
<input
type="email"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="email@example.com"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Your Feedback</label>
<Textarea
value={feedback}
onChange={e => setFeedback(e.target.value)}
placeholder="Tell us what you think..."
rows={6}
/>
</div>
<button className="w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
Submit Feedback
</button>
</div>
</div>
)
}
export const FeedbackForm: Story = {
render: () => <FeedbackFormDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Code snippet
const CodeSnippetDemo = () => {
const [code, setCode] = useState(`function hello() {
console.log("Hello, world!");
}`)
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Code Editor</h3>
<Textarea
value={code}
onChange={e => setCode(e.target.value)}
className="font-mono"
rows={8}
/>
<div className="mt-4 flex gap-2">
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Run Code
</button>
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
Copy
</button>
</div>
</div>
)
}
export const CodeSnippet: Story = {
render: () => <CodeSnippetDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Message composer
const MessageComposerDemo = () => {
const [message, setMessage] = useState('')
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Compose Message</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">To</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Recipient name"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Subject</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Message subject"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Message</label>
<Textarea
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="Type your message here..."
rows={8}
/>
</div>
<div className="flex gap-2">
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Send Message
</button>
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
Save Draft
</button>
</div>
</div>
</div>
)
}
export const MessageComposer: Story = {
render: () => <MessageComposerDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Bio editor
const BioEditorDemo = () => {
const [bio, setBio] = useState('Software developer passionate about building great products.')
const maxLength = 200
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Edit Your Bio</h3>
<Textarea
value={bio}
onChange={e => setBio(e.target.value.slice(0, maxLength))}
placeholder="Tell us about yourself..."
rows={4}
/>
<div className="mt-2 flex items-center justify-between text-xs">
<span className={bio.length > maxLength * 0.9 ? 'text-orange-600' : 'text-gray-500'}>
{bio.length}
{' '}
/
{maxLength}
{' '}
characters
</span>
{bio.length > maxLength * 0.9 && (
<span className="text-orange-600">
{maxLength - bio.length}
{' '}
characters remaining
</span>
)}
</div>
<div className="mt-4 rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-xs font-medium text-gray-600">Preview:</div>
<p className="text-sm text-gray-800">{bio || 'Your bio will appear here...'}</p>
</div>
</div>
)
}
export const BioEditor: Story = {
render: () => <BioEditorDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - JSON editor
const JSONEditorDemo = () => {
const [json, setJson] = useState(`{
"name": "John Doe",
"age": 30,
"email": "john@example.com"
}`)
const [isValid, setIsValid] = useState(true)
const validateJSON = (value: string) => {
try {
JSON.parse(value)
setIsValid(true)
}
catch {
setIsValid(false)
}
}
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">JSON Editor</h3>
<span className={`rounded-sm px-2 py-1 text-xs ${isValid ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{isValid ? '✓ Valid' : '✗ Invalid'}
</span>
</div>
<Textarea
value={json}
onChange={(e) => {
setJson(e.target.value)
validateJSON(e.target.value)
}}
className="font-mono"
destructive={!isValid}
rows={10}
/>
<div className="mt-4 flex gap-2">
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" disabled={!isValid}>
Save JSON
</button>
<button
className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
onClick={() => {
try {
const formatted = JSON.stringify(JSON.parse(json), null, 2)
setJson(formatted)
}
catch {
// Invalid JSON, do nothing
}
}}
>
Format
</button>
</div>
</div>
)
}
export const JSONEditor: Story = {
render: () => <JSONEditorDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Task description
const TaskDescriptionDemo = () => {
const [title, setTitle] = useState('Implement user authentication')
const [description, setDescription] = useState('Add login and registration functionality with JWT tokens.')
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Create New Task</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Task Title</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Description</label>
<Textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Describe the task in detail..."
rows={6}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Priority</label>
<select className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm">
<option>Low</option>
<option>Medium</option>
<option>High</option>
<option>Urgent</option>
</select>
</div>
<button className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Create Task
</button>
</div>
</div>
)
}
export const TaskDescription: Story = {
render: () => <TaskDescriptionDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
export const Playground: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
placeholder: 'Enter text...',
rows: 4,
disabled: false,
destructive: false,
value: '',
},
}

View File

@ -0,0 +1,60 @@
import type { VariantProps } from 'class-variance-authority'
import type { CSSProperties } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { cva } from 'class-variance-authority'
import * as React from 'react'
const textareaVariants = cva(
'',
{
variants: {
size: {
small: 'rounded-md py-1 system-xs-regular',
regular: 'rounded-md px-3 system-sm-regular',
large: 'rounded-lg px-4 system-md-regular',
},
},
defaultVariants: {
size: 'regular',
},
},
)
export type TextareaProps = {
value: string | number
disabled?: boolean
destructive?: boolean
styleCss?: CSSProperties
ref?: React.Ref<HTMLTextAreaElement>
onFocus?: React.FocusEventHandler<HTMLTextAreaElement>
onBlur?: React.FocusEventHandler<HTMLTextAreaElement>
} & React.TextareaHTMLAttributes<HTMLTextAreaElement> & VariantProps<typeof textareaVariants>
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, value, onChange, disabled, size, destructive, styleCss, onFocus, onBlur, ...props }, ref) => {
return (
<textarea
ref={ref}
onFocus={onFocus}
onBlur={onBlur}
style={styleCss}
className={cn(
'min-h-20 w-full appearance-none border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
textareaVariants({ size }),
disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled',
destructive && 'border-components-input-border-destructive bg-components-input-bg-destructive text-components-input-text-filled hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive',
className,
)}
value={value ?? ''}
onChange={onChange}
disabled={disabled}
data-testid="text-area"
{...props}
>
</textarea>
)
},
)
Textarea.displayName = 'Textarea'
export default Textarea

View File

@ -1,7 +1,6 @@
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { PipelineTemplate } from '@/models/pipeline'
import { Button } from '@langgenius/dify-ui/button'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
@ -10,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { useInvalidCustomizedTemplateList, useUpdateTemplateInfo } from '@/service/use-pipeline'
type EditPipelineInfoProps = {
@ -57,7 +57,8 @@ const EditPipelineInfo = ({
setShowAppIconPicker(false)
}, [])
const handleDescriptionChange = useCallback((value: string) => {
const handleDescriptionChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value
setDescription(value)
}, [])
@ -132,8 +133,7 @@ const EditPipelineInfo = ({
{t('knowledgeDescription', { ns: 'datasetPipeline' })}
</label>
<Textarea
aria-label={t('knowledgeDescription', { ns: 'datasetPipeline' })}
onValueChange={handleDescriptionChange}
onChange={handleDescriptionChange}
value={description}
placeholder={t('knowledgeDescriptionPlaceholder', { ns: 'datasetPipeline' })}
/>

View File

@ -5,12 +5,12 @@ import type { DataSet } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { updateDatasetSetting } from '@/service/datasets'
import AppIcon from '../../base/app-icon'
import AppIconPicker from '../../base/app-icon-picker'
@ -117,7 +117,7 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset
{t('form.desc', { ns: 'datasetSettings' })}
</div>
<div className="w-full">
<Textarea aria-label={t('form.desc', { ns: 'datasetSettings' })} value={description} onValueChange={value => setDescription(value)} className="resize-none" placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''} />
<Textarea value={description} onChange={e => setDescription(e.target.value)} className="resize-none" placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''} />
</div>
</div>
</div>

View File

@ -3,11 +3,11 @@ import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { Member } from '@/models/common'
import type { DataSet, DatasetPermission, IconInfo } from '@/models/datasets'
import type { AppIconType } from '@/types/app'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import PermissionSelector from '../../permission-selector'
const rowClass = 'flex gap-x-1'
@ -85,12 +85,11 @@ const BasicInfoSection = ({
</div>
<div className="grow">
<Textarea
aria-label={t('form.desc', { ns: 'datasetSettings' })}
disabled={!currentDataset?.embedding_available}
className="resize-none"
placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''}
value={description}
onValueChange={value => setDescription(value)}
onChange={e => setDescription(e.target.value)}
/>
</div>
</div>

View File

@ -1,7 +1,7 @@
import type { ChangeEvent } from 'react'
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import {
memo,
useCallback,
@ -9,6 +9,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Textarea from '@/app/components/base/textarea'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
@ -52,9 +53,9 @@ const SummaryIndexSetting = ({
})
}, [onSummaryIndexSettingChange])
const handleSummaryIndexPromptChange = useCallback((value: string) => {
const handleSummaryIndexPromptChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
onSummaryIndexSettingChange?.({
summary_prompt: value,
summary_prompt: e.target.value,
})
}, [onSummaryIndexSettingChange])
@ -94,9 +95,8 @@ const SummaryIndexSetting = ({
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
<Textarea
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
value={summaryIndexSetting?.summary_prompt ?? ''}
onValueChange={handleSummaryIndexPromptChange}
onChange={handleSummaryIndexPromptChange}
disabled={readonly}
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
/>
@ -166,9 +166,8 @@ const SummaryIndexSetting = ({
</div>
<div className="grow">
<Textarea
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
value={summaryIndexSetting?.summary_prompt ?? ''}
onValueChange={handleSummaryIndexPromptChange}
onChange={handleSummaryIndexPromptChange}
disabled={readonly}
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
/>
@ -215,9 +214,8 @@ const SummaryIndexSetting = ({
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
<Textarea
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
value={summaryIndexSetting?.summary_prompt ?? ''}
onValueChange={handleSummaryIndexPromptChange}
onChange={handleSummaryIndexPromptChange}
disabled={readonly}
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
/>

View File

@ -3,7 +3,6 @@ import type { AppIconType } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useDebounceFn, useKeyPress } from 'ahooks'
import * as React from 'react'
@ -11,6 +10,7 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { useProviderContext } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
@ -145,11 +145,10 @@ const CreateAppModal = ({
<div className="pt-2">
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('newApp.captionDescription', { ns: 'app' })}</div>
<Textarea
aria-label={t('newApp.captionDescription', { ns: 'app' })}
className="resize-none"
placeholder={t('newApp.appDescriptionPlaceholder', { ns: 'app' }) || ''}
value={description}
onValueChange={value => setDescription(value)}
onChange={e => setDescription(e.target.value)}
/>
</div>
{/* answer icon */}

View File

@ -1,9 +1,9 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { InputVarType } from '@/app/components/workflow/types'
type Props = {
@ -55,9 +55,8 @@ const AppInputsForm = ({
if (form.type === InputVarType.paragraph) {
return (
<Textarea
aria-label={label}
value={inputs[variable] || ''}
onValueChange={value => handleFormChange(variable, value)}
onChange={e => handleFormChange(variable, e.target.value)}
placeholder={label}
/>
)

View File

@ -523,7 +523,9 @@ describe('useToolSelectorState Hook', () => {
)
act(() => {
result.current.handleDescriptionChange('new description')
result.current.handleDescriptionChange({
target: { value: 'new description' },
} as React.ChangeEvent<HTMLTextAreaElement>)
})
expect(onSelect).toHaveBeenCalledWith(
@ -1722,7 +1724,9 @@ describe('Edge Cases', () => {
)
act(() => {
result.current.handleDescriptionChange('')
result.current.handleDescriptionChange({
target: { value: '' },
} as React.ChangeEvent<HTMLTextAreaElement>)
})
expect(onSelect).toHaveBeenCalledWith(

View File

@ -1,6 +1,24 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, disabled, placeholder }: {
value?: string
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
disabled?: boolean
placeholder?: string
}) => (
<textarea
data-testid="description-textarea"
value={value || ''}
onChange={onChange}
disabled={disabled}
placeholder={placeholder}
/>
),
}))
vi.mock('../../../../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
@ -50,28 +68,28 @@ describe('ToolBaseForm', () => {
it('should render description textarea', () => {
render(<ToolBaseForm {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByTestId('description-textarea')).toBeInTheDocument()
})
it('should disable textarea when no provider_name in value', () => {
render(<ToolBaseForm {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByTestId('description-textarea')).toBeDisabled()
})
it('should enable textarea when value has provider_name', () => {
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
render(<ToolBaseForm {...defaultProps} value={value} />)
expect(screen.getByRole('textbox')).not.toBeDisabled()
expect(screen.getByTestId('description-textarea')).not.toBeDisabled()
})
it('should call onDescriptionChange when textarea content changes', () => {
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
render(<ToolBaseForm {...defaultProps} value={value} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated' } })
expect(mockOnDescriptionChange).toHaveBeenCalledWith('Updated', expect.any(Object))
fireEvent.change(screen.getByTestId('description-textarea'), { target: { value: 'Updated' } })
expect(mockOnDescriptionChange).toHaveBeenCalled()
})
it('should show ReadmeEntrance when provider has plugin_unique_identifier', () => {

View File

@ -4,8 +4,8 @@ import type { FC } from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import { ReadmeEntrance } from '../../../readme-panel/entrance'
import ToolTrigger from './tool-trigger'
@ -23,7 +23,7 @@ type ToolBaseFormProps = {
onPanelShowStateChange?: (state: boolean) => void
onSelectTool: (tool: ToolDefaultValue) => void
onSelectMultipleTool: (tools: ToolDefaultValue[]) => void
onDescriptionChange: (value: string) => void
onDescriptionChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
}
const ToolBaseForm: FC<ToolBaseFormProps> = ({
@ -85,10 +85,9 @@ const ToolBaseForm: FC<ToolBaseFormProps> = ({
</div>
<Textarea
className="resize-none"
aria-label={t('detailPanel.toolSelector.descriptionLabel', { ns: 'plugin' })}
placeholder={t('detailPanel.toolSelector.descriptionPlaceholder', { ns: 'plugin' })}
value={value?.extra?.description || ''}
onValueChange={onDescriptionChange}
onChange={onDescriptionChange}
disabled={!value?.provider_name}
/>
</div>

View File

@ -1,3 +1,4 @@
import type * as React from 'react'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -160,8 +161,9 @@ describe('useToolSelectorState', () => {
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
)
const event = { target: { value: 'New description' } } as React.ChangeEvent<HTMLTextAreaElement>
act(() => {
result.current.handleDescriptionChange('New description')
result.current.handleDescriptionChange(event)
})
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({

View File

@ -144,14 +144,14 @@ export const useToolSelectorState = ({
onSelectMultiple?.(toolValues)
}, [getToolValue, onSelectMultiple])
const handleDescriptionChange = useCallback((description: string) => {
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!value)
return
onSelect({
...value,
extra: {
...value.extra,
description: description || '',
description: e.target.value || '',
},
})
}, [value, onSelect])

View File

@ -44,6 +44,17 @@ vi.mock('@/app/components/base/input', () => ({
),
}))
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, ...props }: Record<string, unknown>) => (
<textarea
data-testid="description-textarea"
value={value as string}
onChange={onChange as () => void}
{...props}
/>
),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ onClick }: { onClick?: () => void }) => (
<div data-testid="app-icon" onClick={onClick} />
@ -104,7 +115,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should initialize description as empty', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const textarea = screen.getByRole('textbox', { name: 'pipeline.common.publishAsPipeline.description' }) as HTMLTextAreaElement
const textarea = screen.getByTestId('description-textarea') as HTMLTextAreaElement
expect(textarea.value).toBe('')
})
@ -148,7 +159,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should update description when textarea changes', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const textarea = screen.getByRole('textbox', { name: 'pipeline.common.publishAsPipeline.description' })
const textarea = screen.getByTestId('description-textarea')
fireEvent.change(textarea, { target: { value: 'My description' } })
expect((textarea as HTMLTextAreaElement).value).toBe('My description')
@ -222,7 +233,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
const nameInput = screen.getByTestId('name-input')
fireEvent.change(nameInput, { target: { value: ' Trimmed Name ' } })
const textarea = screen.getByRole('textbox', { name: 'pipeline.common.publishAsPipeline.description' })
const textarea = screen.getByTestId('description-textarea')
fireEvent.change(textarea, { target: { value: ' Some desc ' } })
fireEvent.click(screen.getByText('workflow.common.publish'))

View File

@ -3,13 +3,13 @@ import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { IconInfo } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { RiCloseLine } from '@remixicon/react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { useWorkflowStore } from '@/app/components/workflow/store'
type PublishAsKnowledgePipelineModalProps = {
@ -118,10 +118,9 @@ const PublishAsKnowledgePipelineModal = ({
</div>
<Textarea
className="resize-none"
aria-label={t('common.publishAsPipeline.description', { ns: 'pipeline' })}
placeholder={t('common.publishAsPipeline.descriptionPlaceholder', { ns: 'pipeline' }) || ''}
value={description}
onValueChange={value => setDescription(value)}
onChange={e => setDescription(e.target.value)}
/>
</div>
</div>

View File

@ -7,7 +7,6 @@ import type { VisionFile, VisionSettings } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import {
RiLoader2Line,
RiPlayLargeLine,
@ -19,6 +18,7 @@ import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uplo
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -159,11 +159,10 @@ const RunOnce: FC<IRunOnceProps> = ({
)}
{item.type === 'paragraph' && (
<Textarea
aria-label={item.name}
className="h-[104px] sm:text-xs"
placeholder={item.name}
value={inputs[item.key] as string}
onValueChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
/>
)}
{item.type === 'number' && (

View File

@ -13,7 +13,6 @@ import {
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiSettings2Line } from '@remixicon/react'
import { useDebounce, useGetState } from 'ahooks'
@ -24,6 +23,7 @@ import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import EmojiPicker from '@/app/components/base/emoji-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import LabelSelector from '@/app/components/tools/labels/selector'
import { parseParamsSchema } from '@/service/tools'
import { LinkExternal02 } from '../../base/icons/src/vender/line/general'
@ -280,10 +280,9 @@ const EditCustomCollectionModal: FC<Props> = ({
</div>
<Textarea
aria-label={t('createTool.schema', { ns: 'tools' })}
className="h-[240px] resize-none"
value={schema}
onValueChange={value => setSchema(value)}
onChange={e => setSchema(e.target.value)}
placeholder={t('createTool.schemaPlaceHolder', { ns: 'tools' })!}
/>
</div>

View File

@ -4,11 +4,11 @@ import type {
} from '@/app/components/tools/types'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Textarea from '@/app/components/base/textarea'
import MCPServerParamItem from '@/app/components/tools/mcp/mcp-server-param-item'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import {
@ -154,12 +154,12 @@ const MCPServerModal = ({
<div className="system-xs-regular text-text-destructive-secondary">*</div>
</div>
<Textarea
aria-label={t('mcp.server.modal.description', { ns: 'tools' })}
className="h-[96px] resize-none"
value={description}
placeholder={t('mcp.server.modal.descriptionPlaceholder', { ns: 'tools' })}
onValueChange={value => setDescription(value)}
/>
onChange={e => setDescription(e.target.value)}
>
</Textarea>
</div>
{latestParams.length > 0 && (

View File

@ -1,7 +1,7 @@
'use client'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
type Props = {
data?: any
@ -25,12 +25,12 @@ const MCPServerParamItem = ({
<div className="max-w-full min-w-0 system-xs-medium wrap-break-word text-text-tertiary">{data.type}</div>
</div>
<Textarea
aria-label={data.label}
className="h-8 resize-none"
value={value}
placeholder={t('mcp.server.modal.parametersPlaceholder', { ns: 'tools' })}
onValueChange={value => onChange(value)}
/>
onChange={e => onChange(e.target.value)}
>
</Textarea>
</div>
)
}

View File

@ -14,7 +14,6 @@ import {
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { produce } from 'immer'
@ -26,6 +25,7 @@ import Divider from '@/app/components/base/divider'
import EmojiPickerInner from '@/app/components/base/emoji-picker/Inner'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import LabelSelector from '@/app/components/tools/labels/selector'
import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal'
import MethodSelector from '@/app/components/tools/workflow-tool/method-selector'
@ -303,10 +303,9 @@ export function WorkflowToolDrawer({
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.description', { ns: 'tools' })}</div>
<Textarea
aria-label={t('createTool.description', { ns: 'tools' })}
placeholder={t('createTool.descriptionPlaceholder', { ns: 'tools' }) || ''}
value={description}
onValueChange={value => setDescription(value)}
onChange={e => setDescription(e.target.value)}
/>
</div>
{/* Tool Input */}

View File

@ -4,7 +4,6 @@ import type { InputVar } from '../../../../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import {
RiDeleteBinLine,
} from '@remixicon/react'
@ -19,6 +18,7 @@ import { Variable02 } from '@/app/components/base/icons/src/vender/solid/develop
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import Input from '@/app/components/base/input'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import Textarea from '@/app/components/base/textarea'
import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { Resolution, TransferMethod } from '@/types/app'
@ -170,9 +170,8 @@ const FormItem: FC<Props> = ({
{
type === InputVarType.paragraph && (
<Textarea
aria-label={typeof payload.label === 'object' ? payload.label.variable : payload.label}
value={value || ''}
onValueChange={value => onChange(value)}
onChange={e => onChange(e.target.value)}
placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label}
autoFocus={autoFocus}
/>

View File

@ -2,7 +2,6 @@
import type { FC } from 'react'
import type { AssignerNodeOperation } from '../../types'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { RiDeleteBinLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
@ -11,6 +10,7 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import ListNoDataPlaceholder from '@/app/components/workflow/nodes/_base/components/list-no-data-placeholder'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
@ -190,9 +190,8 @@ const VarList: FC<Props> = ({
)}
{assignedVarType === 'string' && (
<Textarea
aria-label={item.variable_selector?.join('.') || t('nodes.assigner.setParameter', { ns: 'workflow' })}
value={item.value as string}
onValueChange={value => handleToAssignedVarChange(index)(value)}
onChange={e => handleToAssignedVarChange(index)(e.target.value)}
className="w-full"
/>
)}

View File

@ -3,11 +3,11 @@ import type { FC } from 'react'
import type { HttpNodeType } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import { useNodesInteractions } from '@/app/components/workflow/hooks'
import { parseCurl } from './curl-parser'
@ -56,10 +56,9 @@ const CurlPanel: FC<Props> = ({ nodeId, isShow, onHide, handleCurlImport }) => {
<div>
<Textarea
aria-label={t('nodes.http.curl.title', { ns: 'workflow' })}
value={inputString}
className="my-3 h-40 w-full grow"
onValueChange={value => setInputString(value)}
onChange={e => setInputString(e.target.value)}
placeholder={t('nodes.http.curl.placeholder', { ns: 'workflow' })!}
/>
</div>

View File

@ -2,12 +2,12 @@ import type { FC } from 'react'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Model } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { RiCloseLine, RiSparklingFill } from '@remixicon/react'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Textarea from '@/app/components/base/textarea'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
type ModelInfo = {
@ -38,8 +38,8 @@ const PromptEditor: FC<PromptEditorProps> = ({
}) => {
const { t } = useTranslation()
const handleInstructionChange = useCallback((value: string) => {
onInstructionChange(value)
const handleInstructionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
onInstructionChange(e.target.value)
}, [onInstructionChange])
return (
@ -90,11 +90,10 @@ const PromptEditor: FC<PromptEditorProps> = ({
</div>
<div className="flex items-center">
<Textarea
aria-label={t('nodes.llm.jsonSchema.instruction', { ns: 'workflow' })}
className="h-[364px] resize-none px-2 py-1"
value={instruction}
placeholder={t('nodes.llm.jsonSchema.promptPlaceholder', { ns: 'workflow' })}
onValueChange={handleInstructionChange}
onChange={handleInstructionChange}
/>
</div>
</div>

View File

@ -1,9 +1,9 @@
import type { FC } from 'react'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Textarea from '@/app/components/base/textarea'
export type AdvancedOptionsType = {
enum: string
@ -22,8 +22,8 @@ const AdvancedOptions: FC<AdvancedOptionsProps> = ({
// const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
const [enumValue, setEnumValue] = useState(options.enum)
const handleEnumChange = useCallback((value: string) => {
setEnumValue(value)
const handleEnumChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEnumValue(e.target.value)
}, [])
const handleEnumBlur = useCallback((e: React.FocusEvent<HTMLTextAreaElement>) => {
@ -51,11 +51,10 @@ const AdvancedOptions: FC<AdvancedOptionsProps> = ({
Enum
</div>
<Textarea
aria-label="Enum"
size="small"
className="min-h-6"
value={enumValue}
onValueChange={handleEnumChange}
onChange={handleEnumChange}
onBlur={handleEnumBlur}
placeholder="abcd, 1, 1.5, etc."
/>

View File

@ -4,13 +4,13 @@ import type {
import type {
Var,
} from '@/app/components/workflow/types'
import { Textarea } from '@langgenius/dify-ui/textarea'
import {
useCallback,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -49,10 +49,6 @@ const FormItem = ({
onChange(e.target.value)
}, [onChange])
const handleValueChange = useCallback((value: string) => {
onChange(value)
}, [onChange])
const handleChange = useCallback((value: any) => {
onChange(value)
}, [onChange])
@ -96,9 +92,8 @@ const FormItem = ({
{
value_type === ValueType.constant && var_type === VarType.string && (
<Textarea
aria-label={item.label}
value={value}
onValueChange={handleValueChange}
onChange={handleInputChange}
className="min-h-12 w-full"
/>
)

View File

@ -6,7 +6,6 @@ import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useBoolean } from 'ahooks'
import * as React from 'react'
@ -15,6 +14,7 @@ import { useTranslation } from 'react-i18next'
import Field from '@/app/components/app/configuration/config-var/config-modal/field'
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { ChangeType } from '@/app/components/workflow/types'
import { checkKeys } from '@/utils/var'
import { ParamType } from '../../types'
@ -175,9 +175,8 @@ const AddExtractParameter: FC<Props> = ({
)}
<Field title={t(`${i18nPrefix}.addExtractParameterContent.description`, { ns: 'workflow' })}>
<Textarea
aria-label={t(`${i18nPrefix}.addExtractParameterContent.description`, { ns: 'workflow' })}
value={param.description}
onValueChange={value => handleParamChange('description')(value)}
onChange={e => handleParamChange('description')(e.target.value)}
placeholder={t(`${i18nPrefix}.addExtractParameterContent.descriptionPlaceholder`, { ns: 'workflow' })!}
/>
</Field>

View File

@ -2,11 +2,11 @@ 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 { Textarea } from '@langgenius/dify-ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@langgenius/dify-ui/toggle-group'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Markdown } from '@/app/components/base/markdown'
import Textarea from '@/app/components/base/textarea'
import { ChunkCardList } from '@/app/components/rag-pipeline/components/chunk-card-list'
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
import { ChunkingMode } from '@/models/datasets'
@ -91,12 +91,11 @@ export function DisplayContent(props: DisplayContentProps) {
previewType === PreviewType.Markdown
? (
<Textarea
aria-label="Markdown content"
readOnly={readonly}
disabled={readonly}
className="h-full border-none bg-transparent p-0 text-text-secondary hover:bg-transparent focus:bg-transparent focus:shadow-none"
value={mdString as any}
onValueChange={value => handleTextChange?.(value)}
onChange={e => handleTextChange?.(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>

View File

@ -2,9 +2,9 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FileUploadConfigResponse } from '@/models/common'
import type { VarInInspect } from '@/types/workflow'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import Textarea from '@/app/components/base/textarea'
import ErrorMessage from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message'
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
@ -46,12 +46,11 @@ export const TextEditorSection = ({
)
: (
<Textarea
aria-label="Value"
readOnly={textEditorDisabled}
disabled={textEditorDisabled || isTruncated}
className={cn('h-full', isTruncated && 'pt-[48px]')}
value={typeof value === 'number' ? value : String(value ?? '')}
onValueChange={value => onTextChange(value)}
onChange={e => onTextChange(e.target.value)}
/>
)}
</>