From 8eb6a19784811f6b5e1c33eb6ebd0ad4b69fca4e Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:03:31 +0800 Subject: [PATCH] refactor: normalize search input and dify-ui focus states (#37413) --- eslint-suppressions.json | 10 - packages/dify-ui/src/autocomplete/index.tsx | 3 +- packages/dify-ui/src/combobox/index.tsx | 4 +- packages/dify-ui/src/form-control-shared.ts | 6 +- .../src/number-field/__tests__/index.spec.tsx | 32 +- .../src/number-field/index.stories.tsx | 521 ++++++++++-------- packages/dify-ui/src/number-field/index.tsx | 24 +- packages/dify-ui/src/textarea/index.tsx | 3 +- web/app/components/apps/list.tsx | 10 - web/app/components/base/infotip/index.tsx | 2 +- .../search-input/__tests__/index.spec.tsx | 17 + .../components/base/search-input/index.tsx | 3 +- .../datasets/list/__tests__/index.spec.tsx | 6 +- web/app/components/datasets/list/index.tsx | 11 +- 14 files changed, 374 insertions(+), 278 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e10b181157..0f5754a813 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2579,11 +2579,6 @@ "count": 3 } }, - "web/app/components/base/search-input/index.tsx": { - "jsx-a11y/no-autofocus": { - "count": 1 - } - }, "web/app/components/base/svg-gallery/index.tsx": { "node/prefer-global/buffer": { "count": 1 @@ -3511,11 +3506,6 @@ "count": 1 } }, - "web/app/components/datasets/list/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/metadata/edit-metadata-batch/add-row.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 diff --git a/packages/dify-ui/src/autocomplete/index.tsx b/packages/dify-ui/src/autocomplete/index.tsx index 9721ff1946..b362a9450f 100644 --- a/packages/dify-ui/src/autocomplete/index.tsx +++ b/packages/dify-ui/src/autocomplete/index.tsx @@ -6,6 +6,7 @@ import type { Placement } from '../placement' import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete' import { cva } from 'class-variance-authority' import { cn } from '../cn' +import { textControlCompoundFocusClassName } from '../form-control-shared' import { overlayIndicatorClassName, overlayLabelClassName, @@ -49,7 +50,7 @@ const autocompleteInputGroupVariants = cva( [ 'group/autocomplete flex w-full min-w-0 items-center border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-hidden transition-[background-color,border-color,box-shadow]', 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', - 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs', + textControlCompoundFocusClassName, 'data-focused:border-components-input-border-active data-focused:bg-components-input-bg-active data-focused:shadow-xs', 'data-disabled:cursor-not-allowed data-disabled:border-transparent data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled', 'data-disabled:hover:border-transparent data-disabled:hover:bg-components-input-bg-disabled', diff --git a/packages/dify-ui/src/combobox/index.tsx b/packages/dify-ui/src/combobox/index.tsx index a07855cf64..6931a2df7a 100644 --- a/packages/dify-ui/src/combobox/index.tsx +++ b/packages/dify-ui/src/combobox/index.tsx @@ -6,7 +6,7 @@ import type { Placement } from '../placement' import { Combobox as BaseCombobox } from '@base-ui/react/combobox' import { cva } from 'class-variance-authority' import { cn } from '../cn' -import { formLabelClassName } from '../form-control-shared' +import { formLabelClassName, textControlCompoundFocusClassName } from '../form-control-shared' import { overlayIndicatorClassName, overlayLabelClassName, @@ -113,7 +113,7 @@ const comboboxInputGroupVariants = cva( [ 'group/combobox flex w-full min-w-0 items-center border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-hidden transition-[background-color,border-color,box-shadow]', 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', - 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs', + textControlCompoundFocusClassName, 'data-focused:border-components-input-border-active data-focused:bg-components-input-bg-active data-focused:shadow-xs', 'data-popup-open:border-components-input-border-active data-popup-open:bg-components-input-bg-active', 'data-disabled:cursor-not-allowed data-disabled:border-transparent data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled', diff --git a/packages/dify-ui/src/form-control-shared.ts b/packages/dify-ui/src/form-control-shared.ts index d8454fce52..288c1f7fcd 100644 --- a/packages/dify-ui/src/form-control-shared.ts +++ b/packages/dify-ui/src/form-control-shared.ts @@ -2,12 +2,16 @@ import { cva } from 'class-variance-authority' export const formLabelClassName = 'w-fit py-1 text-text-secondary system-sm-medium data-disabled:cursor-not-allowed' +export const textControlFocusClassName = 'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs' + +export const textControlCompoundFocusClassName = 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs' + 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', + textControlFocusClassName, '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', diff --git a/packages/dify-ui/src/number-field/__tests__/index.spec.tsx b/packages/dify-ui/src/number-field/__tests__/index.spec.tsx index d7a7cc8c82..c05b46eb90 100644 --- a/packages/dify-ui/src/number-field/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/number-field/__tests__/index.spec.tsx @@ -7,6 +7,10 @@ import type { } from '../index' import * as React from 'react' import { render } from 'vitest-browser-react' +import { + FieldLabel, + FieldRoot, +} from '../../field' import { NumberField, NumberFieldControls, @@ -44,11 +48,7 @@ const renderNumberField = ({ return render( - + {unitProps && ( {unitChildren} @@ -75,6 +75,7 @@ describe('NumberField wrapper', () => { }) await expect.element(screen.getByTestId('group')).toHaveClass('rounded-lg') + await expect.element(screen.getByTestId('group')).toHaveClass('focus-within:border-components-input-border-active') await expect.element(screen.getByTestId('group')).toHaveClass('custom-group') }) @@ -89,8 +90,25 @@ describe('NumberField wrapper', () => { }) await expect.element(screen.getByTestId('group')).toHaveClass('rounded-[10px]') - await expect.element(screen.getByTestId('input')).toHaveClass('px-4') - await expect.element(screen.getByTestId('input')).toHaveClass('py-2') + await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveClass('px-4') + await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveClass('py-2') + }) + + it('should surface field invalid state on the visual group', async () => { + const screen = await render( + + Amount + + + + + + , + ) + + await expect.element(screen.getByTestId('group')).toHaveAttribute('data-invalid') + await expect.element(screen.getByTestId('group')).toHaveClass('data-invalid:border-components-input-border-destructive') + await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveAttribute('aria-invalid', 'true') }) it('should set input defaults and forward passthrough props', async () => { diff --git a/packages/dify-ui/src/number-field/index.stories.tsx b/packages/dify-ui/src/number-field/index.stories.tsx index 641a1d2051..284148c56f 100644 --- a/packages/dify-ui/src/number-field/index.stories.tsx +++ b/packages/dify-ui/src/number-field/index.stories.tsx @@ -1,5 +1,13 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' +import { Button } from '../button' +import { + FieldDescription, + FieldError, + FieldLabel, + FieldRoot, +} from '../field' +import { Form } from '../form' import { NumberField, NumberFieldControls, @@ -8,104 +16,7 @@ import { NumberFieldIncrement, NumberFieldInput, NumberFieldUnit, -} from '.' -import { cn } from '../cn' - -type DemoFieldProps = { - label: string - helperText: string - placeholder: string - size: 'medium' | 'large' - unit?: string - defaultValue?: number | null - min?: number - max?: number - step?: number - disabled?: boolean - readOnly?: boolean - showCurrentValue?: boolean - widthClassName?: string - formatValue?: (value: number | null) => string -} - -const formatNumericValue = (value: number | null, unit?: string) => { - if (value === null) - return 'Empty' - - if (!unit) - return String(value) - - return `${value} ${unit}` -} - -const FieldLabel = ({ - inputId, - label, - helperText, -}: Pick & { inputId: string }) => ( -
- -

{helperText}

-
-) - -const DemoField = ({ - label, - helperText, - placeholder, - size, - unit, - defaultValue, - min, - max, - step, - disabled, - readOnly, - showCurrentValue, - widthClassName, - formatValue, -}: DemoFieldProps) => { - const inputId = React.useId() - const [value, setValue] = React.useState(defaultValue ?? null) - - return ( -
- - - - - {unit && {unit}} - - - - - - - {showCurrentValue && ( -

- Current value: - {' '} - {formatValue ? formatValue(value) : formatNumericValue(value, unit)} -

- )} -
- ) -} +} from './index' const meta = { title: 'Base/Form/NumberField', @@ -114,7 +25,7 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound numeric input built on Base UI NumberField. Stories explicitly enumerate the shipped CVA variants, then cover realistic numeric-entry cases such as decimals, empty values, range limits, read-only, and disabled states.', + component: 'Compound numeric input built on Base UI NumberField. Use it with FieldRoot for labelled, described, and validated form fields.', }, }, }, @@ -122,164 +33,312 @@ const meta = { } satisfies Meta export default meta + type Story = StoryObj -export const VariantMatrix: Story = { +type NumberFieldExampleProps = { + id: string + label: string + name: string + defaultValue?: number + min?: number + max?: number + step?: number | 'any' + placeholder?: string + unit?: string + size?: 'medium' | 'large' + disabled?: boolean + readOnly?: boolean +} + +function NumberFieldExample({ + id, + label, + name, + defaultValue, + min, + max, + step, + placeholder, + unit, + size = 'medium', + disabled, + readOnly, +}: NumberFieldExampleProps) { + return ( +
+ + + + + {unit && {unit}} + + + + + + +
+ ) +} + +export const Basic: Story = { render: () => ( -
- - - + ), +} + +export const Sizes: Story = { + render: () => ( +
+ -
), } -export const DecimalInputs: Story = { +export const States: Story = { render: () => ( -
- + + Placeholder + + + + % + + + + + + + + + Filled + + + + % + + + + + + + + + Invalid + + + + % + + + + + + + Use a value from 0 to 100. + + + Disabled + + + + + + + + + + + + Read-only + + + + % + + + + + + + +
+ ), +} + +function ControlledDemo() { + const [value, setValue] = React.useState(0.82) + + return ( + + Score threshold + value === null ? 'Empty' : value.toFixed(2)} - /> - value === null ? 'Empty' : value.toFixed(1)} - /> - value === null ? 'Empty' : value.toFixed(2)} - /> - value === null ? 'Empty' : `${value.toFixed(1)} s`} - /> + format={{ + maximumFractionDigits: 2, + }} + onValueChange={setValue} + > + + + + + + + + + + Current value: + {' '} + {value === null ? 'Empty' : value.toFixed(2)} + + + ) +} + +export const Controlled: Story = { + render: () => ( +
+
), } -export const BoundariesAndStates: Story = { +function FormDemo() { + const [savedValue, setSavedValue] = React.useState(null) + + return ( +
{ + setSavedValue(String(values.topK ?? '')) + }} + > + + Top K + + + + + + + + + + Choose how many chunks are returned. + Top K is required. + Use at least 1. + Use 10 or fewer. + +
+ +
+ {savedValue && ( +
+ Saved: + {' '} + {savedValue} +
+ )} +
+ ) +} + +export const WithField: Story = { + render: () => , +} + +export const Formatting: Story = { render: () => ( -
- - - - +
+ + Budget + + + + + + + + + + + + Temperature + + + + + + + + + +
), } diff --git a/packages/dify-ui/src/number-field/index.tsx b/packages/dify-ui/src/number-field/index.tsx index 7307cf1a8b..2ff6fb53c0 100644 --- a/packages/dify-ui/src/number-field/index.tsx +++ b/packages/dify-ui/src/number-field/index.tsx @@ -5,6 +5,7 @@ import type * as React from 'react' import { NumberField as BaseNumberField } from '@base-ui/react/number-field' import { cva } from 'class-variance-authority' import { cn } from '../cn' +import { textControlCompoundFocusClassName } from '../form-control-shared' export const NumberField = BaseNumberField.Root export type NumberFieldRootProps = BaseNumberField.Root.Props @@ -13,7 +14,9 @@ export const numberFieldGroupVariants = cva( [ 'group/number-field flex w-full min-w-0 items-stretch overflow-hidden border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-hidden transition-[background-color,border-color,box-shadow]', 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', + textControlCompoundFocusClassName, 'data-focused:border-components-input-border-active data-focused:bg-components-input-bg-active data-focused:shadow-xs', + 'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive', 'data-disabled:cursor-not-allowed data-disabled:border-transparent data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled', 'data-disabled:hover:border-transparent data-disabled:hover:bg-components-input-bg-disabled', 'data-readonly:shadow-none data-readonly:hover:border-transparent data-readonly:hover:bg-components-input-bg-normal motion-reduce:transition-none', @@ -32,7 +35,12 @@ export const numberFieldGroupVariants = cva( ) export type NumberFieldSize = NonNullable['size']> -export type NumberFieldGroupProps = BaseNumberField.Group.Props & VariantProps +export type NumberFieldGroupProps + = Omit + & VariantProps + & { + className?: string + } export function NumberFieldGroup({ className, @@ -67,7 +75,12 @@ export const numberFieldInputVariants = cva( }, ) -export type NumberFieldInputProps = Omit & VariantProps +export type NumberFieldInputProps + = Omit + & VariantProps + & { + className?: string + } export function NumberFieldInput({ className, @@ -185,7 +198,12 @@ type NumberFieldButtonVariantProps = Omit< 'direction' > -export type NumberFieldButtonProps = BaseNumberField.Increment.Props & NumberFieldButtonVariantProps +export type NumberFieldButtonProps + = Omit + & NumberFieldButtonVariantProps + & { + className?: string + } const incrementAriaLabel = 'Increment value' const decrementAriaLabel = 'Decrement value' diff --git a/packages/dify-ui/src/textarea/index.tsx b/packages/dify-ui/src/textarea/index.tsx index fa6b37b0dd..7075a3bd51 100644 --- a/packages/dify-ui/src/textarea/index.tsx +++ b/packages/dify-ui/src/textarea/index.tsx @@ -6,13 +6,14 @@ import type * as React from 'react' import { Field as BaseField } from '@base-ui/react/field' import { cva } from 'class-variance-authority' import { cn } from '../cn' +import { textControlFocusClassName } from '../form-control-shared' const textareaVariants = cva( [ 'min-h-20 w-full appearance-none overflow-auto 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', + textControlFocusClassName, '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', diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 84be087333..84148d0980 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -217,16 +217,6 @@ function List({ placeholder={t('operation.search', { ns: 'common' })} aria-label={t('gotoAnything.actions.searchApplications', { ns: 'app' })} /> - {!!keywords && ( - - )}
diff --git a/web/app/components/base/search-input/__tests__/index.spec.tsx b/web/app/components/base/search-input/__tests__/index.spec.tsx index 9db43d074f..7092961f08 100644 --- a/web/app/components/base/search-input/__tests__/index.spec.tsx +++ b/web/app/components/base/search-input/__tests__/index.spec.tsx @@ -35,6 +35,23 @@ describe('SearchInput', () => { const clearButton = screen.getByLabelText('common.operation.clear') expect(clearButton).toBeInTheDocument() }) + + it('uses the design-system focus treatment for the clear button', () => { + render( {}} />) + + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) + expect(clearButton).toHaveClass( + 'right-1.5', + 'size-5', + 'focus-visible:bg-components-input-bg-hover', + 'focus-visible:ring-2', + 'focus-visible:ring-state-accent-solid', + 'focus-visible:ring-inset', + ) + expect(clearButton).not.toHaveClass('size-4') + expect(clearButton).not.toHaveClass('focus-visible:ring-1') + expect(clearButton).not.toHaveClass('focus-visible:ring-components-input-border-active') + }) }) describe('Interaction', () => { diff --git a/web/app/components/base/search-input/index.tsx b/web/app/components/base/search-input/index.tsx index 4682dd936f..539a7d2b77 100644 --- a/web/app/components/base/search-input/index.tsx +++ b/web/app/components/base/search-input/index.tsx @@ -84,6 +84,7 @@ export function SearchInput({ onValueChange(e.currentTarget.value) }} autoComplete="off" + // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus={autoFocus} enterKeyHint="search" /> @@ -91,7 +92,7 @@ export function SearchInput({