fix(ui): expose slider values and unify class name resolution (#43293)

This commit is contained in:
yyh authored and GitHub committed 2026-09-30 16:10:57 +00:00
1 parent 5e3065ec7d
commit 3c17c90bea
26 files changed
+204 -488

No files matched your search

@@ -5,8 +5,6 @@ import {
Autocomplete,
AutocompleteClear,
AutocompleteEmpty,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteInput,
AutocompleteInputGroup,
AutocompleteItem,
@@ -16,7 +14,6 @@ import {
AutocompletePopup,
AutocompletePortal,
AutocompletePositioner,
AutocompleteSeparator,
AutocompleteStatus,
AutocompleteTrigger,
useAutocompleteFilteredItems,
@@ -94,13 +91,13 @@ describe('Autocomplete wrappers', () => {
.not.toBe(restingBoxShadow)
})
it('should set input defaults and forward passthrough props', async () => {
it('should disable autocomplete and expose placeholder and required state', async () => {
const screen = await renderAutocomplete({
children: (
<AutocompleteInputGroup>
<AutocompleteInput
aria-label="Search suggestions"
className="custom-input"
placeholder="Find a resource"
required
/>
@@ -117,9 +114,6 @@ describe('Autocomplete wrappers', () => {
await expect
.element(screen.getByRole('combobox', { name: 'Search suggestions' }))
.toBeRequired()
await expect
.element(screen.getByRole('combobox', { name: 'Search suggestions' }))
.toHaveClass('custom-input')
})
it('should not inject input-only attributes into a custom textarea', async () => {
@@ -245,45 +239,6 @@ describe('Autocomplete wrappers', () => {
expect(onPopupClick).toHaveBeenCalledTimes(1)
})
it('should forward custom classes to label separator item text and indicator', async () => {
const screen = await renderWithSafeViewport(
<Autocomplete open defaultValue="workflow" items={['workflow']}>
<AutocompleteInputGroup>
<AutocompleteInput aria-label="Search suggestions" />
</AutocompleteInputGroup>
<AutocompletePortal>
<AutocompletePositioner>
<AutocompletePopup role="dialog" aria-label="autocomplete popup">
<AutocompleteList role="listbox" aria-label="autocomplete list">
<AutocompleteGroup items={['workflow']}>
<AutocompleteGroupLabel className="custom-label">
Resources
</AutocompleteGroupLabel>
<AutocompleteSeparator className="custom-separator" data-testid="separator" />
<AutocompleteItem value="workflow" className="custom-item">
<AutocompleteItemText className="custom-text">Workflow</AutocompleteItemText>
<AutocompleteItemIndicator
className="custom-indicator"
data-testid="indicator"
/>
</AutocompleteItem>
</AutocompleteGroup>
</AutocompleteList>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompletePortal>
</Autocomplete>,
)
await expect.element(screen.getByText('Resources')).toHaveClass('custom-label')
await expect.element(screen.getByTestId('separator')).toHaveClass('custom-separator')
await expect
.element(screen.getByRole('option', { name: 'Workflow' }))
.toHaveClass('custom-item')
await expect.element(screen.getByText('Workflow')).toHaveClass('custom-text')
await expect.element(screen.getByTestId('indicator')).toHaveClass('custom-indicator')
})
it('should navigate function-rendered items with arrow keys', async () => {
const screen = await renderWithSafeViewport(
<Autocomplete open defaultValue="" items={['workflow', 'dataset', 'app']}>
@@ -47,15 +47,6 @@ describe('Avatar', () => {
})
})
describe('className prop', () => {
it('should merge className with avatar variant classes on root', async () => {
const screen = await render(<Avatar name="Test" avatar={null} className="custom-class" />)
const root = screen.container.firstElementChild as HTMLElement
expect(root).toHaveClass('custom-class')
})
})
describe('onLoadingStatusChange', () => {
it('should show fallback until the image loads and forward status changes', async () => {
const { images, restore } = stubImageLoader()
@@ -66,28 +66,4 @@ describe('Button', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('className merging', () => {
it('merges custom className with variant classes', async () => {
const screen = await render(<Button className="custom-class">Click me</Button>)
const btn = screen.getByRole('button').element()
expect(btn).toHaveClass('custom-class')
})
})
describe('ref forwarding', () => {
it('forwards ref to the button element', async () => {
let buttonRef: HTMLButtonElement | null = null
await render(
<Button
ref={(el) => {
buttonRef = el
}}
>
Click me
</Button>,
)
expect(buttonRef).toBeInstanceOf(HTMLButtonElement)
})
})
})
@@ -10,7 +10,6 @@ import {
ComboboxCollection,
ComboboxEmpty,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxInput,
ComboboxInputGroup,
ComboboxInputTrigger,
@@ -22,7 +21,6 @@ import {
ComboboxPopup,
ComboboxPortal,
ComboboxPositioner,
ComboboxSeparator,
ComboboxStatus,
ComboboxTrigger,
ComboboxValue,
@@ -206,13 +204,13 @@ describe('Combobox wrappers', () => {
.not.toBe(restingBoxShadow)
})
it('should set input defaults and forward passthrough props', async () => {
it('should disable autocomplete and expose placeholder and required state', async () => {
const screen = await renderInputCombobox({
children: (
<ComboboxInputGroup>
<ComboboxInput
aria-label="Search resources"
className="custom-input"
placeholder="Find a resource"
required
/>
@@ -229,9 +227,6 @@ describe('Combobox wrappers', () => {
await expect
.element(screen.getByRole('combobox', { name: 'Search resources' }))
.toBeRequired()
await expect
.element(screen.getByRole('combobox', { name: 'Search resources' }))
.toHaveClass('custom-input')
})
it('should not inject input-only attributes into a custom textarea', async () => {
@@ -403,43 +398,6 @@ describe('Combobox wrappers', () => {
expect(status.element().getBoundingClientRect().height).toBe(0)
})
it('should forward custom classes to group label separator item text and indicator', async () => {
const screen = await renderWithSafeViewport(
<Combobox open defaultValue="workflow" items={['workflow']}>
<ComboboxTrigger aria-label="Resource type">
<ComboboxValue />
</ComboboxTrigger>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup aria-label="Choose a resource">
<ComboboxInput aria-label="Filter resources" />
<ComboboxList data-testid="custom-list">
<ComboboxGroup items={['workflow']}>
<ComboboxGroupLabel className="custom-label">Resources</ComboboxGroupLabel>
<ComboboxSeparator className="custom-separator" data-testid="separator" />
<ComboboxItem value="workflow" className="custom-item">
<ComboboxItemText className="custom-text">Workflow</ComboboxItemText>
<ComboboxItemIndicator className="custom-indicator" data-testid="indicator" />
</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>,
)
await expect.element(screen.getByText('Resources')).toHaveClass('custom-label')
await expect.element(screen.getByTestId('separator')).toHaveClass('custom-separator')
await expect
.element(screen.getByRole('option', { name: 'Workflow' }))
.toHaveClass('custom-item')
await expect
.element(screen.getByTestId('custom-list').getByText('Workflow'))
.toHaveClass('custom-text')
await expect.element(screen.getByTestId('indicator')).toHaveClass('custom-indicator')
})
it('should navigate function-rendered items with arrow keys', async () => {
const screen = await renderWithSafeViewport(
<Combobox defaultValue="workflow" items={['workflow', 'dataset', 'app']}>
@@ -525,16 +483,16 @@ describe('Combobox wrappers', () => {
await expect.element(screen.getByText('No reviewers selected')).toBeInTheDocument()
})
it('should render chip wrappers and default remove button label', async () => {
it('should give chip remove buttons a default accessible name and non-submit type', async () => {
const screen = await renderWithSafeViewport(
<Combobox multiple defaultValue={['maya']} items={['maya', 'nora']}>
<ComboboxInputGroup>
<ComboboxChips className="custom-chips" data-testid="chips">
<ComboboxChips data-testid="chips">
<ComboboxValue<string, true>>
{(selectedValue) => (
<React.Fragment>
{selectedValue?.map((item) => (
<ComboboxChip key={item} className="custom-chip">
<ComboboxChip key={item}>
<span>{item}</span>
<ComboboxChipRemove data-testid="remove-chip" />
</ComboboxChip>
@@ -548,10 +506,6 @@ describe('Combobox wrappers', () => {
</Combobox>,
)
await expect.element(screen.getByTestId('chips')).toHaveClass('custom-chips')
await expect
.element(screen.getByText('maya').element().parentElement!)
.toHaveClass('custom-chip')
await expect
.element(screen.getByRole('button', { name: 'Remove selected item' }))
.toHaveAttribute('type', 'button')
@@ -8,6 +8,7 @@ import { cn } from '../../cn'
import { DirectionProvider, useDirection } from '../../direction-provider'
import { formLabelClassName } from '../../form-control-shared'
import { IconButton } from '../../icon-button'
import { resolveClassName } from '../../internals/resolve-class-name'
import { Popover, PopoverContent, PopoverTrigger } from '../../popover'
type PickerOpenChangeDetails = Omit<
@@ -278,7 +279,7 @@ function PickerTrigger({
'flex h-8 w-63 max-w-full items-center justify-between gap-0.5 rounded-lg bg-components-input-bg-normal ps-3 pe-2 text-start system-sm-regular text-components-input-text-filled',
'hover:bg-state-base-hover-alt focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-state-accent-solid data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-popup-open:bg-state-base-hover-alt',
!field.serializedValue && 'text-text-tertiary in-data-[theme=dark]:text-text-secondary',
typeof className === 'function' ? className(state) : className,
resolveClassName(className, state),
)
}
>
@@ -364,7 +365,7 @@ function PickerContent({
cn(
'forced-colors:[&_button:focus-visible]:outline-2 forced-colors:[&_button:focus-visible]:outline-[Highlight] forced-colors:[&_button:focus-visible]:outline-solid',
'flex w-63 max-w-(--available-width) flex-col overflow-hidden border-0 p-0 inset-ring-[0.5px] inset-ring-components-panel-border backdrop-blur-[5px]',
typeof className === 'function' ? className(state) : className,
resolveClassName(className, state),
)
}
initialFocus={() =>
@@ -346,36 +346,6 @@ describe('dropdown-menu wrapper', () => {
})
describe('DropdownMenuSeparator', () => {
it('should forward passthrough props and handlers when separator props are provided', async () => {
const handleMouseEnter = vi.fn()
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSeparator
aria-label="actions divider"
id="menu-separator"
onMouseEnter={handleMouseEnter}
/>
</DropdownMenuContent>
</DropdownMenu>,
)
screen
.getByRole('separator', { name: 'actions divider' })
.element()
.dispatchEvent(
new MouseEvent('mouseover', {
bubbles: true,
}),
)
await expect
.element(screen.getByRole('separator', { name: 'actions divider' }))
.toHaveAttribute('id', 'menu-separator')
expect(handleMouseEnter).toHaveBeenCalledTimes(1)
})
it('should keep surrounding menu rows rendered when separator is placed between items', async () => {
const screen = await render(
<DropdownMenu open>
@@ -1,18 +0,0 @@
import { render } from 'vitest-browser-react'
import { Fieldset, FieldsetLegend } from '../index'
describe('Fieldset primitives', () => {
it('should forward className to the fieldset and legend', async () => {
const screen = await render(
<Fieldset className="custom-root">
<FieldsetLegend className="custom-legend">Permissions</FieldsetLegend>
</Fieldset>,
)
const legend = screen.getByText('Permissions').element() as HTMLElement
const fieldset = legend.closest('fieldset') as HTMLElement
await expect.element(fieldset).toHaveClass('custom-root')
await expect.element(legend).toHaveClass('custom-legend')
})
})
@@ -4,21 +4,6 @@ import { Input } from '../../input'
import { Form } from '../index'
describe('Form primitive', () => {
it('should render a native named form and merge custom class names', async () => {
const screen = await render(
<Form aria-label="profile form" className="custom-form">
<Field name="name">
<FieldLabel>Name</FieldLabel>
<Input defaultValue="Ada" />
</Field>
</Form>,
)
await expect
.element(screen.getByRole('form', { name: 'profile form' }))
.toHaveClass('custom-form')
})
it('should call onFormSubmit with submitted values', async () => {
const onFormSubmit = vi.fn()
const screen = await render(
@@ -15,12 +15,6 @@ describe('Kbd', () => {
await expect.element(screen.getByText('⌘')).toHaveAttribute('data-disabled')
await expect.element(screen.getByText('⌘')).not.toHaveAttribute('aria-disabled')
})
it('merges custom classes with the design-system recipe', async () => {
const screen = await render(<Kbd className="custom-key h-5">K</Kbd>)
await expect.element(screen.getByText('K')).toHaveClass('custom-key')
})
})
describe('KbdGroup', () => {
@@ -1,10 +1,8 @@
import type {
NumberFieldControlsProps,
NumberFieldDecrementProps,
NumberFieldGroupProps,
NumberFieldIncrementProps,
NumberFieldInputProps,
NumberFieldUnitProps,
} from '../index'
import * as React from 'react'
import { userEvent } from 'vite-plus/test/browser'
@@ -18,14 +16,11 @@ import {
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldUnit,
} from '../index'
type RenderNumberFieldOptions = {
defaultValue?: number
groupProps?: Partial<NumberFieldGroupProps>
inputProps?: Partial<NumberFieldInputProps>
unitProps?: Partial<NumberFieldUnitProps> & { children?: React.ReactNode }
controlsProps?: Partial<NumberFieldControlsProps>
incrementProps?: Partial<NumberFieldIncrementProps>
decrementProps?: Partial<NumberFieldDecrementProps>
@@ -33,24 +28,15 @@ type RenderNumberFieldOptions = {
const renderNumberField = ({
defaultValue = 8,
groupProps,
inputProps,
unitProps,
controlsProps,
incrementProps,
decrementProps,
}: RenderNumberFieldOptions = {}) => {
const { children: unitChildren = 'ms', ...restUnitProps } = unitProps ?? {}
return render(
<NumberField defaultValue={defaultValue}>
<NumberFieldGroup data-testid="group" {...groupProps}>
<NumberFieldGroup data-testid="group">
<NumberFieldInput aria-label="Amount" {...inputProps} />
{unitProps && (
<NumberFieldUnit data-testid="unit" {...restUnitProps}>
{unitChildren}
</NumberFieldUnit>
)}
{(controlsProps || incrementProps || decrementProps) && (
<NumberFieldControls data-testid="controls" {...controlsProps}>
<NumberFieldIncrement data-testid="increment" {...incrementProps} />
@@ -78,16 +64,6 @@ describe('NumberField wrapper', () => {
.not.toBe(restingBoxShadow)
})
it('should merge custom className on the group', async () => {
const screen = await renderNumberField({
groupProps: {
className: 'custom-group',
},
})
await expect.element(screen.getByTestId('group')).toHaveClass('custom-group')
})
it('should surface field invalid state on the visual group', async () => {
const screen = await render(
<Field name="amount" invalid>
@@ -106,10 +82,9 @@ describe('NumberField wrapper', () => {
.toHaveAttribute('aria-invalid', 'true')
})
it('should set input defaults and forward passthrough props', async () => {
it('should disable autocomplete and expose placeholder and required state', async () => {
const screen = await renderNumberField({
inputProps: {
className: 'custom-input',
placeholder: 'Regular placeholder',
required: true,
},
@@ -125,38 +100,6 @@ describe('NumberField wrapper', () => {
.element(screen.getByRole('textbox', { name: 'Amount' }))
.toHaveAttribute('placeholder', 'Regular placeholder')
await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toBeRequired()
await expect
.element(screen.getByRole('textbox', { name: 'Amount' }))
.toHaveClass('custom-input')
})
})
describe('Unit and controls', () => {
it('should forward passthrough props to the unit', async () => {
const screen = await renderNumberField({
unitProps: {
className: 'custom-unit',
title: 'unit-title',
},
})
await expect.element(screen.getByTestId('unit')).toHaveTextContent('ms')
await expect.element(screen.getByTestId('unit')).toHaveAttribute('title', 'unit-title')
await expect.element(screen.getByTestId('unit')).toHaveClass('custom-unit')
})
it('should forward passthrough props to controls', async () => {
const screen = await renderNumberField({
controlsProps: {
className: 'custom-controls',
title: 'controls-title',
},
})
await expect
.element(screen.getByTestId('controls'))
.toHaveAttribute('title', 'controls-title')
await expect.element(screen.getByTestId('controls')).toHaveClass('custom-controls')
})
})
@@ -238,25 +181,6 @@ describe('NumberField wrapper', () => {
.element(screen.getByRole('button', { name: 'Decrement from label' }))
.not.toHaveAttribute('aria-label')
})
it('should forward passthrough props to control buttons', async () => {
const screen = await renderNumberField({
controlsProps: {},
incrementProps: {
className: 'custom-increment',
},
decrementProps: {
className: 'custom-decrement',
title: 'decrement-title',
},
})
await expect.element(screen.getByTestId('increment')).toHaveClass('custom-increment')
await expect.element(screen.getByTestId('decrement')).toHaveClass('custom-decrement')
await expect
.element(screen.getByTestId('decrement'))
.toHaveAttribute('title', 'decrement-title')
})
})
})
@@ -344,9 +344,7 @@ describe('Pagination primitive', () => {
<PaginationRoot page={3} totalPages={5} onPageChange={onPageChange}>
<ol>
<li>
<PaginationPage page={4} className="custom-page">
Four
</PaginationPage>
<PaginationPage page={4}>Four</PaginationPage>
</li>
</ol>
</PaginationRoot>,
@@ -354,9 +352,6 @@ describe('Pagination primitive', () => {
await screen.getByRole('button', { name: 'Go to page 4' }).click()
await expect
.element(screen.getByRole('button', { name: 'Go to page 4' }))
.toHaveClass('custom-page')
expect(onPageChange).toHaveBeenCalledWith(4)
})
@@ -184,21 +184,13 @@ describe('Radio', () => {
it('should support custom items with a visual RadioControl', async () => {
const screen = await render(
<RadioGroup defaultValue="card" aria-label="Card choice">
<RadioItem
value="card"
nativeButton
render={<button type="button" className="custom-card" />}
>
<RadioItem value="card" nativeButton render={<button type="button" />}>
<span>Card option</span>
<RadioControl className="custom-control" />
<RadioControl />
</RadioItem>
</RadioGroup>,
)
await expect
.element(screen.getByRole('radio', { name: 'Card option' }))
.toHaveClass('custom-card')
expect(screen.container.querySelector('.custom-control')).toBeInTheDocument()
await expect
.element(screen.getByRole('radio', { name: 'Card option' }))
.toHaveAttribute('data-checked', '')
@@ -33,47 +33,26 @@ const stubElementMetric = (
const renderScrollArea = (
options: {
rootClassName?: string
contentStyle?: React.CSSProperties
viewportClassName?: string
viewportStyle?: React.CSSProperties
verticalScrollbarClassName?: string
horizontalScrollbarClassName?: string
verticalThumbClassName?: string
horizontalThumbClassName?: string
} = {},
) => {
return render(
<ScrollArea className={options.rootClassName ?? 'h-40 w-40'} data-testid="scroll-area-root">
<ScrollAreaViewport
data-testid="scroll-area-viewport"
style={options.viewportStyle}
className={options.viewportClassName}
>
<ScrollArea className="h-40 w-40" data-testid="scroll-area-root">
<ScrollAreaViewport data-testid="scroll-area-viewport" style={options.viewportStyle}>
<ScrollAreaContent data-testid="scroll-area-content" style={options.contentStyle}>
<div className="h-48 w-48">Scrollable content</div>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar
keepMounted
data-testid="scroll-area-vertical-scrollbar"
className={options.verticalScrollbarClassName}
>
<ScrollAreaThumb
data-testid="scroll-area-vertical-thumb"
className={options.verticalThumbClassName}
/>
<ScrollAreaScrollbar keepMounted data-testid="scroll-area-vertical-scrollbar">
<ScrollAreaThumb data-testid="scroll-area-vertical-thumb" />
</ScrollAreaScrollbar>
<ScrollAreaScrollbar
keepMounted
orientation="horizontal"
data-testid="scroll-area-horizontal-scrollbar"
className={options.horizontalScrollbarClassName}
>
<ScrollAreaThumb
data-testid="scroll-area-horizontal-thumb"
className={options.horizontalThumbClassName}
/>
<ScrollAreaThumb data-testid="scroll-area-horizontal-thumb" />
</ScrollAreaScrollbar>
</ScrollArea>,
)
@@ -110,14 +89,14 @@ describe('scroll area', () => {
<ScrollArea className="h-40 w-40" data-testid="scroll-area-root">
<ScrollAreaViewport
aria-labelledby="installed-apps-label"
className="custom-viewport-class"
role="region"
>
<ScrollAreaContent className="custom-content-class">
<ScrollAreaContent>
<div className="h-48 w-20">Scrollable content</div>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className="custom-scrollbar-class">
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
@@ -125,14 +104,11 @@ describe('scroll area', () => {
)
const viewport = screen.getByRole('region', { name: 'Installed apps' })
const content = screen.getByText('Scrollable content').element().parentElement
await expect
.element(screen.getByTestId('scroll-area-root'))
.not.toHaveAttribute('role', 'region')
await expect.element(viewport).toHaveClass('custom-viewport-class')
await expect.element(viewport).toHaveAccessibleName('Installed apps')
expect(content).toHaveClass('custom-content-class')
await expect.element(screen.getByText('Scrollable content')).toBeInTheDocument()
})
@@ -220,32 +196,6 @@ describe('scroll area', () => {
})
describe('Props', () => {
it('should forward className to the viewport', async () => {
const screen = await renderScrollArea({
viewportClassName: 'custom-viewport-class',
})
await expect
.element(screen.getByTestId('scroll-area-viewport'))
.toHaveClass('custom-viewport-class')
})
it('should let callers control scrollbar inset spacing via margin-based className overrides', async () => {
const screen = await renderScrollArea({
verticalScrollbarClassName:
'data-[orientation=vertical]:my-2 data-[orientation=vertical]:-me-3',
horizontalScrollbarClassName:
'data-[orientation=horizontal]:mx-2 data-[orientation=horizontal]:mb-2',
})
await expect
.element(screen.getByTestId('scroll-area-vertical-scrollbar'))
.toHaveClass('data-[orientation=vertical]:my-2', 'data-[orientation=vertical]:-me-3')
await expect
.element(screen.getByTestId('scroll-area-horizontal-scrollbar'))
.toHaveClass('data-[orientation=horizontal]:mx-2', 'data-[orientation=horizontal]:mb-2')
})
it('should let vertical layouts override the content minimum width without important CSS', async () => {
const screen = await renderScrollArea({ contentStyle: { minWidth: 0 } })
const content = screen.getByTestId('scroll-area-content').element()
@@ -0,0 +1,35 @@
import type { SliderValueProps } from '@langgenius/dify-ui/slider'
import { Slider, SliderValue } from '@langgenius/dify-ui/slider'
import { expectTypeOf } from 'vite-plus/test'
function sliderTypeContracts() {
const single = (
<Slider
defaultValue={0.9}
onValueChange={(value) => {
expectTypeOf(value).toEqualTypeOf<number>()
}}
/>
)
const initialRange: readonly [number, number] = [25, 75]
const range = (
<Slider
defaultValue={initialRange}
onValueChange={(value) => {
expectTypeOf(value).toEqualTypeOf<readonly [number, number]>()
}}
/>
)
const renderValue: NonNullable<SliderValueProps['children']> = (formattedValues) =>
formattedValues.join(' – ')
const output = <SliderValue>{renderValue}</SliderValue>
// @ts-expect-error Slider only supports numeric values and ranges.
const invalid = <Slider defaultValue="0.9" />
return [single, range, output, invalid]
}
void sliderTypeContracts
+50 -39
View File
@@ -1,7 +1,15 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { SliderProps } from '.'
import * as React from 'react'
import { Slider, SliderControl, SliderIndicator, SliderLabel, SliderThumb, SliderTrack } from '.'
import { expect } from 'storybook/test'
import {
Slider,
SliderControl,
SliderIndicator,
SliderLabel,
SliderThumb,
SliderTrack,
SliderValue,
} from '.'
const meta = {
title: 'Base/Form/Slider',
@@ -43,21 +51,17 @@ function SliderDemo({
defaultValue: _defaultValue,
...args
}: SliderProps<number>) {
const [value, setValue] = React.useState(initialValue)
return (
<div className="w-[320px] space-y-3">
<Slider {...args} value={value} onValueChange={setValue}>
<SliderLabel className="sr-only">Demo slider</SliderLabel>
<SliderControl>
<SliderTrack>
<SliderIndicator />
<SliderThumb />
</SliderTrack>
</SliderControl>
</Slider>
<div className="text-center system-sm-medium text-text-secondary">{value}</div>
</div>
<Slider {...args} defaultValue={initialValue} className="w-80 flex-col gap-3">
<SliderLabel className="sr-only">Demo slider</SliderLabel>
<SliderControl>
<SliderTrack>
<SliderIndicator />
<SliderThumb />
</SliderTrack>
</SliderControl>
<SliderValue className="text-center system-sm-medium text-text-secondary" />
</Slider>
)
}
@@ -69,6 +73,16 @@ export const Default: Story = {
max: 100,
step: 1,
},
play: async ({ canvas, userEvent }) => {
const slider = canvas.getByRole('slider', { name: 'Demo slider' })
const output = canvas.getByRole('status')
await expect(output).toHaveTextContent('50')
await expect(output).toHaveAttribute('for', slider.id)
await userEvent.tab()
await expect(slider).toHaveFocus()
await userEvent.keyboard('{ArrowRight}')
await expect(output).toHaveTextContent('51')
},
}
export const Decimal: Story = {
@@ -123,33 +137,30 @@ export const ComposedWithLabel: Story = {
type PriceRange = readonly [number, number]
function RangeSliderDemo() {
const [range, setRange] = React.useState<PriceRange>([25, 75])
return (
<div className="w-[320px] space-y-3">
<Slider<PriceRange>
value={range}
onValueChange={setRange}
min={0}
max={100}
className="flex-col gap-1"
>
<SliderLabel>Price range</SliderLabel>
<SliderControl>
<SliderTrack>
<SliderIndicator />
<SliderThumb index={0} aria-label="Minimum price" />
<SliderThumb index={1} aria-label="Maximum price" />
</SliderTrack>
</SliderControl>
</Slider>
<div className="text-center system-sm-medium text-text-secondary">
{range[0]} – {range[1]}
</div>
</div>
<Slider<PriceRange> defaultValue={[25, 75]} min={0} max={100} className="w-80 flex-col gap-1">
<SliderLabel>Price range</SliderLabel>
<SliderControl>
<SliderTrack>
<SliderIndicator />
<SliderThumb index={0} aria-label="Minimum price" />
<SliderThumb index={1} aria-label="Maximum price" />
</SliderTrack>
</SliderControl>
<SliderValue className="mt-2 text-center system-sm-medium text-text-secondary" />
</Slider>
)
}
export const Range: Story = {
render: () => <RangeSliderDemo />,
play: async ({ canvas, userEvent }) => {
const slider = canvas.getByRole('slider', { name: 'Minimum price' })
const output = canvas.getByRole('status')
await expect(output).toHaveTextContent('25 – 75')
await userEvent.tab()
await expect(slider).toHaveFocus()
await userEvent.keyboard('{ArrowRight}')
await expect(output).toHaveTextContent('26 – 75')
},
}
+16 -4
View File
@@ -5,10 +5,10 @@ import { cn } from '../cn'
import { formLabelClassName } from '../form-control-shared'
import { resolveClassName } from '../internals/resolve-class-name'
type SliderValue = number | readonly number[]
type SliderProps<Value extends SliderValue = SliderValue> = BaseSlider.Root.Props<Value>
type SliderRootValue = NonNullable<BaseSlider.Root.Props['value']>
type SliderProps<Value extends SliderRootValue = SliderRootValue> = BaseSlider.Root.Props<Value>
function Slider<Value extends SliderValue = SliderValue>({
function Slider<Value extends SliderRootValue = SliderRootValue>({
className,
...props
}: SliderProps<Value>) {
@@ -37,6 +37,9 @@ function SliderLabel({ className, ...props }: SliderLabelProps) {
)
}
type SliderValueProps = BaseSlider.Value.Props
const SliderValue = BaseSlider.Value
type SliderControlProps = BaseSlider.Control.Props
function SliderControl({ className, ...props }: SliderControlProps) {
@@ -109,7 +112,15 @@ function SliderThumb({ className, ...props }: SliderThumbProps) {
)
}
export { Slider, SliderControl, SliderIndicator, SliderLabel, SliderThumb, SliderTrack }
export {
Slider,
SliderControl,
SliderIndicator,
SliderLabel,
SliderThumb,
SliderTrack,
SliderValue,
}
export type {
SliderControlProps,
@@ -118,4 +129,5 @@ export type {
SliderProps,
SliderThumbProps,
SliderTrackProps,
SliderValueProps,
}
@@ -14,11 +14,6 @@ describe('Switch', () => {
await expect.element(switchElement).toHaveAttribute('data-disabled', '')
})
it('should apply custom className', async () => {
const screen = await render(<Switch checked={false} className="custom-test-class" />)
await expect.element(screen.getByRole('switch')).toHaveClass('custom-test-class')
})
it('should reflect checked state on the root and thumb', async () => {
const screen = await render(<Switch checked={false} />)
const switchElement = screen.getByRole('switch').element()
@@ -1,23 +0,0 @@
import { render } from 'vitest-browser-react'
import { Tabs, TabsList, TabsPanel, TabsTab } from '../index'
describe('Tabs wrappers', () => {
it('forwards className to composable parts', async () => {
const screen = await render(
<Tabs defaultValue="first">
<TabsList className="custom-list">
<TabsTab value="first" className="custom-tab">
First
</TabsTab>
</TabsList>
<TabsPanel value="first" className="custom-panel">
Panel
</TabsPanel>
</Tabs>,
)
await expect.element(screen.getByRole('tablist')).toHaveClass('custom-list')
await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass('custom-tab')
expect(screen.getByText('Panel').element()).toHaveClass('custom-panel')
})
})
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from '@/app/notifications'
import ConfigParamModal from '../config-param-modal'
@@ -46,18 +47,6 @@ vi.mock('@/config', () => ({
ANNOTATION_DEFAULT: { score_threshold: 0.9 },
}))
vi.mock('../score-slider', () => ({
default: ({ value, onChange }: { value: number; onChange: (value: number) => void }) => (
<input
type="range"
min={0}
max={100}
value={value}
onChange={(e) => onChange(Number((e.target as HTMLInputElement).value))}
/>
),
}))
const defaultAnnotationConfig = {
id: 'test-id',
enabled: false,
@@ -182,7 +171,7 @@ describe('ConfigParamModal', () => {
/>,
)
expect(screen.getByRole('slider')).toHaveValue('90')
expect(screen.getByRole('slider')).toHaveValue('0.9')
})
it('should render configConfirmBtn when isInit is false', () => {
@@ -287,8 +276,8 @@ describe('ConfigParamModal', () => {
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('min', '0')
expect(slider).toHaveAttribute('max', '100')
expect(slider).toHaveValue('90')
expect(slider).toHaveAttribute('max', '1')
expect(slider).toHaveValue('0.9')
})
it('should update embedding model when model selector is used', () => {
@@ -404,7 +393,7 @@ describe('ConfigParamModal', () => {
/>,
)
expect(screen.getByRole('slider')).toHaveValue('90')
expect(screen.getByRole('slider')).toHaveValue('0.9')
})
it('should preserve zero score threshold instead of falling back to default', async () => {
@@ -473,6 +462,7 @@ describe('ConfigParamModal', () => {
})
it('should save updated score after slider changes', async () => {
const user = userEvent.setup()
const onSave = vi.fn().mockResolvedValue(undefined)
render(
<ConfigParamModal
@@ -484,7 +474,10 @@ describe('ConfigParamModal', () => {
/>,
)
fireEvent.change(screen.getByRole('slider'), { target: { value: '96' } })
const slider = screen.getByRole('slider')
await user.click(slider)
await user.keyboard('{ArrowRight}')
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('0.91'))
const buttons = screen.getAllByRole('button')
const saveBtn = buttons.find((b) => b.textContent?.includes('initSetup'))
@@ -493,7 +486,7 @@ describe('ConfigParamModal', () => {
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ embedding_provider_name: 'openai' }),
0.96,
0.91,
)
})
})
@@ -99,11 +99,11 @@ const ConfigParamModal: FC<Props> = ({
>
<ScoreSlider
className="mt-1"
value={(annotationConfig.score_threshold ?? ANNOTATION_DEFAULT.score_threshold) * 100}
value={annotationConfig.score_threshold ?? ANNOTATION_DEFAULT.score_threshold}
onChange={(val) => {
setAnnotationConfig({
...annotationConfig,
score_threshold: val / 100,
score_threshold: val,
})
}}
/>
@@ -1,15 +1,44 @@
import { render, screen } from '@testing-library/react'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ScoreSlider from '..'
describe('ScoreSlider', () => {
it('should expose the business score instead of the internal percentage', () => {
render(<ScoreSlider value={90} onChange={vi.fn()} />)
it('should display and update the score with two decimal places', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(<ScoreSlider value={0.9} onChange={onChange} />)
const slider = screen.getByRole('slider', {
name: 'appDebug.feature.annotation.scoreThreshold.title',
})
expect(slider).toHaveAttribute('aria-valuenow', '90')
expect(slider).toHaveAttribute('aria-valuenow', '0.9')
expect(slider).toHaveAttribute('aria-valuetext', '0.90')
expect(screen.getByText('0.90')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('0.90')
expect(screen.getByRole('status')).toHaveAttribute('for', slider.id)
act(() => slider.focus())
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith(0.91, expect.anything())
rerender(<ScoreSlider value={0.91} onChange={onChange} />)
expect(screen.getByRole('status')).toHaveTextContent('0.91')
expect(slider).toHaveAttribute('aria-valuetext', '0.91')
})
it.each([
['{PageUp}', 0.6],
['{PageDown}', 0.4],
['{Shift>}{ArrowRight}{/Shift}', 0.6],
['{Shift>}{ArrowLeft}{/Shift}', 0.4],
])('should adjust the score by 0.10 with %s', async (key, expectedValue) => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ScoreSlider value={0.5} onChange={onChange} />)
const slider = screen.getByRole('slider', {
name: 'appDebug.feature.annotation.scoreThreshold.title',
})
act(() => slider.focus())
await user.keyboard(key)
expect(onChange).toHaveBeenCalledWith(expectedValue, expect.anything())
})
})
@@ -7,6 +7,7 @@ import {
SliderLabel,
SliderThumb,
SliderTrack,
SliderValue,
} from '@langgenius/dify-ui/slider'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@@ -24,7 +25,7 @@ const clamp = (value: number, min: number, max: number) => {
}
const SCORE_MIN = 0
const SCORE_MAX = 100
const SCORE_MAX = 1
const ScoreSlider: FC<Props> = ({ className, value, onChange }) => {
const { t } = useTranslation(['appDebug'])
@@ -32,37 +33,28 @@ const ScoreSlider: FC<Props> = ({ className, value, onChange }) => {
return (
<div className={className}>
<div className="relative mt-3.5">
<Slider
className="w-full"
value={safeValue}
min={SCORE_MIN}
max={SCORE_MAX}
step={1}
onValueChange={onChange}
>
<SliderLabel className="sr-only">
{t(($) => $['feature.annotation.scoreThreshold.title'], { ns: 'appDebug' })}
</SliderLabel>
<SliderControl>
<SliderTrack>
<SliderIndicator />
<SliderThumb
getAriaValueText={(_formattedValue, sliderValue) => (sliderValue / 100).toFixed(2)}
/>
</SliderTrack>
</SliderControl>
</Slider>
<div
className="pointer-events-none absolute -top-4 system-sm-semibold text-text-primary"
style={{
left: `calc(4px + ${safeValue / SCORE_MAX} * (100% - 8px))`,
transform: 'translateX(-50%)',
}}
>
{(safeValue / 100).toFixed(2)}
</div>
</div>
<Slider
className="mt-3.5 flex w-full"
value={safeValue}
min={SCORE_MIN}
max={SCORE_MAX}
step={0.01}
largeStep={0.1}
format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }}
onValueChange={onChange}
>
<SliderLabel className="sr-only">
{t(($) => $['feature.annotation.scoreThreshold.title'], { ns: 'appDebug' })}
</SliderLabel>
<SliderControl>
<SliderTrack>
<SliderIndicator />
<SliderThumb>
<SliderValue className="pointer-events-none absolute bottom-full left-1/2 mb-1.5 -translate-x-1/2 system-sm-semibold whitespace-nowrap text-text-primary" />
</SliderThumb>
</SliderTrack>
</SliderControl>
</Slider>
<div className="mt-2.5 flex items-center justify-between system-xs-semibold-uppercase">
<div className="flex space-x-1 text-util-colors-cyan-cyan-500">
<div>0.0</div>
@@ -7,14 +7,18 @@ describe('trigger-schedule/on-minute-selector', () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<OnMinuteSelector value={15} onChange={onChange} />)
const { rerender } = render(<OnMinuteSelector value={15} onChange={onChange} />)
const slider = screen.getByRole('slider', {
name: 'workflowIntegrations.nodes.triggerSchedule.onMinute',
})
expect(screen.getByRole('status')).toHaveTextContent('15')
expect(screen.getByRole('status')).toHaveAttribute('for', slider.id)
slider.focus()
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith(16, expect.objectContaining({ activeThumbIndex: 0 }))
rerender(<OnMinuteSelector value={16} onChange={onChange} />)
expect(screen.getByRole('status')).toHaveTextContent('16')
})
})
@@ -5,6 +5,7 @@ import {
SliderLabel,
SliderThumb,
SliderTrack,
SliderValue,
} from '@langgenius/dify-ui/slider'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@@ -30,9 +31,7 @@ const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => {
{t(($) => $['nodes.triggerSchedule.onMinute'], { ns: 'workflowIntegrations' })}
</SliderLabel>
<div className="relative flex h-8 items-center rounded-lg bg-components-input-bg-normal">
<div className="flex h-full w-12 shrink-0 items-center justify-center text-[13px] text-components-input-text-filled">
{value}
</div>
<SliderValue className="flex h-full w-12 shrink-0 items-center justify-center text-[13px] text-components-input-text-filled" />
<div className="absolute top-0 left-12 h-full w-px bg-components-panel-bg"></div>
<div className="flex h-full grow items-center pr-3 pl-4">
<SliderControl>
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createReactI18nextMock } from '@/test/i18n-mock'
vi.mock('react-i18next', () =>
+1 -1
View File
@@ -1,6 +1,6 @@
import { renderHook } from '@testing-library/react'
import { renderToString } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { useOAuthCallback } from '../use-oauth'
const originalOpener = window.opener