feat: add dify-ui input primitive (#36446)

This commit is contained in:
yyh
2026-05-21 11:15:38 +08:00
committed by GitHub
parent 0cf9597f52
commit 66f5ab4cfc
17 changed files with 898 additions and 63 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -65,6 +65,10 @@
"types": "./src/form/index.tsx",
"import": "./src/form/index.tsx"
},
"./input": {
"types": "./src/input/index.tsx",
"import": "./src/input/index.tsx"
},
"./meter": {
"types": "./src/meter/index.tsx",
"import": "./src/meter/index.tsx"

View File

@@ -3,8 +3,8 @@
import type { Field as BaseFieldNS } from '@base-ui/react/field'
import type { VariantProps } from 'class-variance-authority'
import { Field as BaseField } from '@base-ui/react/field'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
import { textControlVariants } from '../text-control-variants'
export type FieldRootProps
= Omit<BaseFieldNS.Root.Props, 'className'>
@@ -62,37 +62,11 @@ export function FieldLabel({
)
}
const fieldControlVariants = cva(
[
'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-[3px] system-xs-regular',
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type FieldControlSize = NonNullable<VariantProps<typeof fieldControlVariants>['size']>
export type FieldControlSize = NonNullable<VariantProps<typeof textControlVariants>['size']>
export type FieldControlProps
= Omit<BaseFieldNS.Control.Props, 'className' | 'size'>
& VariantProps<typeof fieldControlVariants>
& VariantProps<typeof textControlVariants>
& {
className?: string
}
@@ -106,7 +80,7 @@ export function FieldControl({
}: FieldControlProps) {
return (
<BaseField.Control
className={cn(fieldControlVariants({ size }), className)}
className={cn(textControlVariants({ size }), className)}
{...props}
/>
)

View File

@@ -0,0 +1,83 @@
import { render } from 'vitest-browser-react'
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '../../field'
import { Form } from '../../form'
import { Input } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Input', () => {
it('should render a labelled Base UI input with design-system classes', async () => {
const screen = await render(
<label>
Workspace name
<Input name="workspaceName" defaultValue="Dify" />
</label>,
)
const input = screen.getByRole('textbox', { name: 'Workspace name' })
await expect.element(input).toHaveValue('Dify')
await expect.element(input).toHaveClass('rounded-lg', 'py-[7px]', 'system-sm-regular')
})
it('should apply size variants shared with FieldControl', async () => {
const screen = await render(
<>
<label>
Small input
<Input size="small" />
</label>
<div>
Large field
<FieldRoot name="largeField">
<FieldLabel>Large field</FieldLabel>
<FieldControl size="large" />
</FieldRoot>
</div>
</>,
)
await expect.element(screen.getByRole('textbox', { name: 'Small input' })).toHaveClass('rounded-md', 'py-[3px]', 'system-xs-regular')
await expect.element(screen.getByRole('textbox', { name: 'Large field' })).toHaveClass('rounded-[10px]', 'py-[7px]', 'system-md-regular')
})
it('should use FieldRoot invalid state', async () => {
const screen = await render(
<FieldRoot name="repositoryUrl" invalid>
<FieldLabel>Repository URL</FieldLabel>
<Input defaultValue="github.com/langgenius" />
</FieldRoot>,
)
const input = screen.getByRole('textbox', { name: 'Repository URL' })
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
await expect.element(input).toHaveAttribute('data-invalid')
await expect.element(input).toHaveClass('data-invalid:border-components-input-border-destructive')
})
it('should integrate with FieldRoot and Base UI Form validation', async () => {
const onFormSubmit = vi.fn()
const screen = await render(
<Form aria-label="account form" onFormSubmit={onFormSubmit}>
<FieldRoot name="email">
<FieldLabel>Email</FieldLabel>
<Input type="email" required />
<FieldError match="valueMissing">Email is required.</FieldError>
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
const input = screen.getByRole('textbox', { name: 'Email' })
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
await vi.waitFor(async () => {
await expect.element(screen.getByText('Email is required.')).toBeInTheDocument()
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
await expect.element(input).toHaveAttribute('data-invalid')
})
expect(onFormSubmit).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,124 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Button } from '../button'
import {
FieldDescription,
FieldError,
FieldLabel,
FieldRoot,
} from '../field'
import { Form } from '../form'
import { Input } from './index'
const meta = {
title: 'Base/Form/Input',
component: Input,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'A standalone text input primitive built on Base UI Input. Use it for labelled text boxes outside FieldControl, and keep FieldControl for full FieldRoot form composition.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Input>
export default meta
type Story = StoryObj<typeof meta>
export const Basic: Story = {
render: () => (
<div className="w-80">
<label htmlFor="workspace-name" className="mb-1 block w-fit py-1 text-text-secondary system-sm-medium">
Workspace name
</label>
<Input
id="workspace-name"
name="workspaceName"
autoComplete="organization"
placeholder="e.g. Acme workspace…"
/>
</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-input">
Small
<Input id="small-input" size="small" name="smallInput" placeholder="e.g. tag…" autoComplete="off" />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="medium-input">
Medium
<Input id="medium-input" name="mediumInput" placeholder="e.g. Production API…" autoComplete="off" />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="large-input">
Large
<Input id="large-input" size="large" name="largeInput" placeholder="e.g. Customer portal…" autoComplete="off" />
</label>
</div>
),
}
export const States: Story = {
render: () => (
<div className="grid w-80 gap-3">
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="placeholder-state">Placeholder</label>
<Input id="placeholder-state" name="placeholderState" placeholder="e.g. Search datasets…" autoComplete="off" />
</div>
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="filled-state">Filled</label>
<Input id="filled-state" name="filledState" defaultValue="Customer knowledge base" autoComplete="off" />
</div>
<div className="grid gap-1">
<FieldRoot name="repositoryUrl" invalid>
<FieldLabel>Invalid</FieldLabel>
<Input
id="invalid-state"
type="url"
inputMode="url"
defaultValue="github.com/langgenius"
autoComplete="off"
spellCheck={false}
/>
<FieldError match>Enter a full URL including https://.</FieldError>
</FieldRoot>
</div>
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="disabled-state">Disabled</label>
<Input id="disabled-state" disabled name="disabledEmail" type="email" inputMode="email" placeholder="name@example.com…" autoComplete="email" spellCheck={false} />
</div>
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="readonly-state">Read-only</label>
<Input id="readonly-state" readOnly name="endpoint" type="url" inputMode="url" defaultValue="https://api.example.com" autoComplete="url" spellCheck={false} />
</div>
</div>
),
}
export const WithField: Story = {
render: () => (
<Form aria-label="Account form" className="grid w-80 gap-4" onFormSubmit={() => undefined}>
<FieldRoot name="email">
<FieldLabel>Email</FieldLabel>
<Input type="email" inputMode="email" required autoComplete="email" placeholder="name@example.com…" spellCheck={false} />
<FieldDescription>Used for account notifications.</FieldDescription>
<FieldError match="valueMissing">Email is required.</FieldError>
<FieldError match="typeMismatch">Enter a valid email address.</FieldError>
</FieldRoot>
<FieldRoot name="repositoryUrl">
<FieldLabel>Repository URL</FieldLabel>
<Input type="url" inputMode="url" required autoComplete="off" placeholder="https://github.com/langgenius/dify…" spellCheck={false} />
<FieldDescription>Use the full GitHub repository URL.</FieldDescription>
<FieldError match="valueMissing">Repository URL is required.</FieldError>
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
</FieldRoot>
<div className="flex justify-end">
<Button type="submit" variant="primary">Save Settings</Button>
</div>
</Form>
),
}

View File

@@ -0,0 +1,31 @@
'use client'
import type { Input as BaseInputNS } from '@base-ui/react/input'
import type { VariantProps } from 'class-variance-authority'
import { Input as BaseInput } from '@base-ui/react/input'
import { cn } from '../cn'
import { textControlVariants } from '../text-control-variants'
export type InputSize = NonNullable<VariantProps<typeof textControlVariants>['size']>
export type InputProps
= Omit<BaseInputNS.Props, 'className' | 'size'>
& VariantProps<typeof textControlVariants>
& {
className?: string
}
export type InputChangeEventDetails = BaseInputNS.ChangeEventDetails
export function Input({
className,
size = 'medium',
...props
}: InputProps) {
return (
<BaseInput
className={cn(textControlVariants({ size }), className)}
{...props}
/>
)
}

View File

@@ -0,0 +1,27 @@
import { cva } from 'class-variance-authority'
export const textControlVariants = cva(
[
'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-[3px] system-xs-regular',
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)

View File

@@ -2,12 +2,12 @@ import type { FC } from 'react'
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 { 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 Input from '../../base/input'
import Textarea from '../../base/textarea'
type VersionInfoModalProps = {
@@ -57,10 +57,6 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
onClose()
}
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value)
}, [])
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setReleaseNotes(e.target.value)
}, [])
@@ -89,17 +85,16 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
</button>
</div>
<div className="flex flex-col gap-y-4 px-6 py-3">
<div className="flex flex-col gap-y-1">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
<FieldRoot name="title" invalid={titleError} className="gap-y-1">
<FieldLabel className="flex h-6 items-center py-0 system-sm-semibold text-text-secondary">
{t('versionHistory.editField.title', { ns: 'workflow' })}
</div>
<Input
</FieldLabel>
<FieldControl
value={title}
placeholder={`${t('versionHistory.nameThisVersion', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`}
onChange={handleTitleChange}
destructive={titleError}
onValueChange={setTitle}
/>
</div>
</FieldRoot>
<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' })}

View File

@@ -22,6 +22,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { toast } from '@langgenius/dify-ui/toast'
import {
Tooltip,
@@ -30,11 +31,10 @@ import {
} from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useId, useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
@@ -209,7 +209,6 @@ const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps>
const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => {} }: AppCardProps) => {
const { t } = useTranslation()
const deleteAppNameInputId = useId()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor } = useAppContext()
const { onPlanInfoChanged } = useProviderContext()
@@ -631,8 +630,8 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('deleteAppConfirmContent', { ns: 'app' })}
</AlertDialogDescription>
<div className="mt-2">
<label htmlFor={deleteAppNameInputId} className="mb-1 block system-sm-regular text-text-secondary">
<FieldRoot name="confirm-app-name" className="mt-2">
<FieldLabel className="mb-1 block py-0 system-sm-regular text-text-secondary">
<Trans
i18nKey="deleteAppConfirmInputLabel"
ns="app"
@@ -641,19 +640,17 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
appName: <span className="system-sm-semibold text-text-primary" translate="no" />,
}}
/>
</label>
<Input
id={deleteAppNameInputId}
name="confirm-app-name"
</FieldLabel>
<FieldControl
type="text"
autoComplete="off"
spellCheck={false}
placeholder={t('deleteAppConfirmInputPlaceholder', { ns: 'app' })}
value={confirmDeleteInput}
onChange={e => setConfirmDeleteInput(e.target.value)}
onValueChange={setConfirmDeleteInput}
className="border-components-input-border-hover bg-components-input-bg-normal focus:border-components-input-border-active focus:bg-components-input-bg-active"
/>
</div>
</FieldRoot>
</div>
<AlertDialogActions>
<AlertDialogCancelButton type="button" disabled={isDeleting}>

View File

@@ -22,6 +22,11 @@ export const inputVariants = cva(
},
)
/**
* @deprecated Use `@langgenius/dify-ui/input` for primitive inputs and
* `@langgenius/dify-ui/field` for form composition. Search inputs should use
* a dedicated composition built on the primitive input.
*/
export type InputProps = {
showLeftIcon?: boolean
showClearIcon?: boolean
@@ -36,6 +41,11 @@ export type InputProps = {
const removeLeadingZeros = (value: string) => value.replace(/^(-?)0+(?=\d)/, '$1')
/**
* @deprecated Use `@langgenius/dify-ui/input` for primitive inputs and
* `@langgenius/dify-ui/field` for form composition. Search inputs should use
* a dedicated composition built on the primitive input.
*/
const Input = React.forwardRef<HTMLInputElement, InputProps>(({
size,
disabled,

View File

@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'
import CrawledResult from '../base/crawled-result'
import CrawledResultItem from '../base/crawled-result-item'
import Header from '../base/header'
import Input from '../base/input'
import Input from '../base/text-input'
const createCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page Title',

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Input from '../input'
import Input from '../text-input'
describe('WebsiteInput', () => {
const onChange = vi.fn()

View File

@@ -3,7 +3,7 @@ import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { Infotip } from '@/app/components/base/infotip'
import Input from './input'
import Input from './text-input'
type Props = {
className?: string

View File

@@ -5,7 +5,7 @@ import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import Input from './input'
import Input from './text-input'
const I18N_PREFIX = 'stepOne.website'

View File

@@ -1,11 +1,11 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Input } from '@langgenius/dify-ui/input'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import Input from '../../base/input'
const I18N_PREFIX = 'stepOne.website'
@@ -21,9 +21,6 @@ const UrlInput: FC<Props> = ({
const { t } = useTranslation()
const docLink = useDocLink()
const [url, setUrl] = useState('')
const handleUrlChange = useCallback((url: string | number) => {
setUrl(url as string)
}, [])
const handleOnRun = useCallback(() => {
if (isRunning)
return
@@ -34,8 +31,9 @@ const UrlInput: FC<Props> = ({
<div className="flex items-center justify-between">
<Input
value={url}
onChange={handleUrlChange}
onValueChange={setUrl}
placeholder={docLink()}
size="small"
/>
<Button
variant="primary"

View File

@@ -48,10 +48,21 @@ const FLOATING_UI_RESTRICTED_IMPORT_PATTERNS = [
},
]
const LEGACY_WEB_INPUT_RESTRICTED_IMPORT_PATTERNS = [
{
group: [
'**/base/input',
'**/base/input/*',
],
message: 'Do not import the deprecated web base Input. Use @langgenius/dify-ui/input for standalone inputs, and @langgenius/dify-ui/field for labelled or validated form composition.',
},
]
export const WEB_RESTRICTED_IMPORT_PATTERNS = [
...NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
...BASE_UI_RESTRICTED_IMPORT_PATTERNS,
...FLOATING_UI_RESTRICTED_IMPORT_PATTERNS,
...LEGACY_WEB_INPUT_RESTRICTED_IMPORT_PATTERNS,
]
export const HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS = {