From ed17b6161f2baaf4142939347043c78c9b67bf8c Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 25 May 2026 10:22:43 +0800 Subject: [PATCH] refactor(dify-ui): refine switch contract (#36539) --- .../src/select/__tests__/index.spec.tsx | 10 + packages/dify-ui/src/select/index.tsx | 1 + .../src/switch/__tests__/index.spec.tsx | 31 +++ packages/dify-ui/src/switch/index.stories.tsx | 261 +++++++++++------- packages/dify-ui/src/switch/index.tsx | 52 ++-- .../src/toast/__tests__/index.spec.tsx | 1 + packages/dify-ui/src/toast/index.tsx | 2 +- 7 files changed, 224 insertions(+), 134 deletions(-) diff --git a/packages/dify-ui/src/select/__tests__/index.spec.tsx b/packages/dify-ui/src/select/__tests__/index.spec.tsx index ccdb13c61d..09450f56af 100644 --- a/packages/dify-ui/src/select/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/select/__tests__/index.spec.tsx @@ -207,6 +207,16 @@ describe('Select wrappers', () => { expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-popup-open:bg-state-base-hover-alt') }) + + it('should include keyboard focus ring classes', async () => { + const screen = await renderOpenSelect() + + await expect.element(screen.getByRole('combobox', { name: 'city select' })).toHaveClass( + 'focus-visible:ring-1', + 'focus-visible:ring-components-input-border-active', + 'focus-visible:ring-inset', + ) + }) }) describe('SelectContent', () => { diff --git a/packages/dify-ui/src/select/index.tsx b/packages/dify-ui/src/select/index.tsx index 3dd145be98..c16d72af98 100644 --- a/packages/dify-ui/src/select/index.tsx +++ b/packages/dify-ui/src/select/index.tsx @@ -24,6 +24,7 @@ const selectTriggerVariants = cva( [ 'group flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden', 'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt', + 'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset', 'data-placeholder:text-components-input-text-placeholder', 'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent', 'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled', diff --git a/packages/dify-ui/src/switch/__tests__/index.spec.tsx b/packages/dify-ui/src/switch/__tests__/index.spec.tsx index 56fe68d34b..28aa8a655c 100644 --- a/packages/dify-ui/src/switch/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/switch/__tests__/index.spec.tsx @@ -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() + 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() @@ -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() + 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() + + 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() expect(screen.container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument() diff --git a/packages/dify-ui/src/switch/index.stories.tsx b/packages/dify-ui/src/switch/index.stories.tsx index 4d47ef688e..2156000bbc 100644 --- a/packages/dify-ui/src/switch/index.stories.tsx +++ b/packages/dify-ui/src/switch/index.stories.tsx @@ -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 -const SwitchDemo = (args: Partial>) => { +type SwitchDemoProps = Partial, 'checked' | 'defaultChecked' | 'onCheckedChange'>> & { + checked?: boolean +} + +const SwitchDemo = (args: SwitchDemoProps) => { const [enabled, setEnabled] = useState(args.checked ?? false) return ( -
- - - {enabled ? 'On' : 'Off'} - -
+ + + Enable auto retry + + + + {enabled ? 'Failures will retry automatically.' : 'Failures require manual retry.'} + + ) } @@ -116,24 +128,24 @@ const AllStatesDemo = () => { {size}
- {}} /> - {}} /> + {}} aria-label={`${size} unchecked switch`} /> + {}} aria-label={`${size} checked switch`} />
- - + +
- - + +
- +