diff --git a/web/app/components/app/configuration/tools/index.tsx b/web/app/components/app/configuration/tools/index.tsx index f348a7718d..51a9e87a97 100644 --- a/web/app/components/app/configuration/tools/index.tsx +++ b/web/app/components/app/configuration/tools/index.tsx @@ -179,7 +179,7 @@ const Tools = () => {
handleSaveExternalDataToolModal({ ...item, enabled }, index)} /> diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-content.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-content.tsx index ed691b84d6..561868eed8 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-content.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-content.tsx @@ -37,7 +37,7 @@ const ModerationContent: FC = ({ ) } handleConfigChange('enabled', v)} /> diff --git a/web/app/components/base/switch/__tests__/index.spec.tsx b/web/app/components/base/switch/__tests__/index.spec.tsx index 127efc987f..02fc90bcc1 100644 --- a/web/app/components/base/switch/__tests__/index.spec.tsx +++ b/web/app/components/base/switch/__tests__/index.spec.tsx @@ -2,6 +2,9 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import Switch from '../index' +import { SwitchSkeleton } from '../skeleton' + +const getThumb = (switchElement: HTMLElement) => switchElement.querySelector('span') describe('Switch', () => { it('should render in unchecked state when value is false', () => { @@ -9,12 +12,14 @@ describe('Switch', () => { const switchElement = screen.getByRole('switch') expect(switchElement).toBeInTheDocument() expect(switchElement).toHaveAttribute('aria-checked', 'false') + expect(switchElement).not.toHaveAttribute('data-checked') }) it('should render in checked state when value is true', () => { render() const switchElement = screen.getByRole('switch') expect(switchElement).toHaveAttribute('aria-checked', 'true') + expect(switchElement).toHaveAttribute('data-checked', '') }) it('should call onChange with next value when clicked', async () => { @@ -28,7 +33,6 @@ describe('Switch', () => { expect(onChange).toHaveBeenCalledWith(true) expect(onChange).toHaveBeenCalledTimes(1) - // Controlled component stays the same until parent updates value. expect(switchElement).toHaveAttribute('aria-checked', 'false') }) @@ -54,7 +58,8 @@ describe('Switch', () => { render() const switchElement = screen.getByRole('switch') - expect(switchElement).toHaveClass('!cursor-not-allowed', '!opacity-50') + expect(switchElement).toHaveClass('data-[disabled]:cursor-not-allowed') + expect(switchElement).toHaveAttribute('data-disabled', '') await user.click(switchElement) expect(onChange).not.toHaveBeenCalled() @@ -62,9 +67,8 @@ describe('Switch', () => { it('should apply correct size classes', () => { const { rerender } = render() - // We only need to find the element once const switchElement = screen.getByRole('switch') - expect(switchElement).toHaveClass('h-2.5', 'w-3.5', 'rounded-sm') + expect(switchElement).toHaveClass('h-2.5', 'w-3.5', 'rounded-[2px]') rerender() expect(switchElement).toHaveClass('h-3', 'w-5') @@ -72,11 +76,8 @@ describe('Switch', () => { rerender() expect(switchElement).toHaveClass('h-4', 'w-7') - rerender() - expect(switchElement).toHaveClass('h-5', 'w-9') - rerender() - expect(switchElement).toHaveClass('h-6', 'w-11') + expect(switchElement).toHaveClass('h-5', 'w-9') }) it('should apply custom className', () => { @@ -84,13 +85,115 @@ describe('Switch', () => { expect(screen.getByRole('switch')).toHaveClass('custom-test-class') }) - it('should apply correct background colors based on value prop', () => { + it('should expose checked state styling hooks on the root and thumb', () => { const { rerender } = render() const switchElement = screen.getByRole('switch') + const thumb = getThumb(switchElement) - expect(switchElement).toHaveClass('bg-components-toggle-bg-unchecked') + expect(switchElement).toHaveClass('bg-components-toggle-bg-unchecked', 'data-[checked]:bg-components-toggle-bg') + expect(thumb).toHaveClass('data-[checked]:translate-x-[14px]') + expect(thumb).not.toHaveAttribute('data-checked') rerender() - expect(switchElement).toHaveClass('bg-components-toggle-bg') + expect(switchElement).toHaveAttribute('data-checked', '') + expect(thumb).toHaveAttribute('data-checked', '') + }) + + it('should expose disabled state styling hooks instead of relying on opacity', () => { + const { rerender } = render() + const switchElement = screen.getByRole('switch') + + expect(switchElement).toHaveClass( + 'data-[disabled]:bg-components-toggle-bg-unchecked-disabled', + 'data-[disabled]:data-[checked]:bg-components-toggle-bg-disabled', + ) + expect(switchElement).toHaveAttribute('data-disabled', '') + + rerender() + expect(switchElement).toHaveAttribute('data-disabled', '') + expect(switchElement).toHaveAttribute('data-checked', '') + }) + + it('should have focus-visible ring styles', () => { + render() + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveClass('focus-visible:ring-2') + }) + + it('should respect prefers-reduced-motion', () => { + render() + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveClass('motion-reduce:transition-none') + }) + + describe('loading state', () => { + it('should render as disabled when loading', async () => { + const onChange = vi.fn() + const user = userEvent.setup() + render() + + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveClass('data-[disabled]:cursor-not-allowed') + expect(switchElement).toHaveAttribute('aria-busy', 'true') + expect(switchElement).toHaveAttribute('data-disabled', '') + + await user.click(switchElement) + expect(onChange).not.toHaveBeenCalled() + }) + + it('should show spinner icon for md and lg sizes', () => { + const { rerender, container } = render() + expect(container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument() + + rerender() + expect(container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument() + }) + + it('should not show spinner for xs and sm sizes', () => { + const { rerender, container } = render() + expect(container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument() + + rerender() + expect(container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument() + }) + + it('should apply disabled data-state hooks when loading', () => { + const { rerender } = render() + const switchElement = screen.getByRole('switch') + + expect(switchElement).toHaveAttribute('data-disabled', '') + + rerender() + expect(switchElement).toHaveAttribute('data-disabled', '') + expect(switchElement).toHaveAttribute('data-checked', '') + }) + }) +}) + +describe('SwitchSkeleton', () => { + it('should render a plain div without switch role', () => { + render() + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + expect(screen.getByTestId('skeleton-switch')).toBeInTheDocument() + }) + + it('should apply skeleton styles', () => { + render() + const el = screen.getByTestId('skeleton-switch') + expect(el).toHaveClass('bg-text-quaternary', 'opacity-20') + }) + + it('should apply correct skeleton size classes', () => { + const { rerender } = render() + const el = screen.getByTestId('s') + expect(el).toHaveClass('h-2.5', 'w-3.5', 'rounded-[2px]') + + rerender() + expect(el).toHaveClass('h-5', 'w-9', 'rounded-[6px]') + }) + + it('should apply custom className to skeleton', () => { + render() + expect(screen.getByTestId('s')).toHaveClass('custom-class') }) }) diff --git a/web/app/components/base/switch/index.stories.tsx b/web/app/components/base/switch/index.stories.tsx index f3a24f2396..06ce46d5ac 100644 --- a/web/app/components/base/switch/index.stories.tsx +++ b/web/app/components/base/switch/index.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { useState } from 'react' +import { useState, useTransition } from 'react' import Switch from '.' +import { SwitchSkeleton } from './skeleton' const meta = { title: 'Base/Data Entry/Switch', @@ -9,7 +10,7 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Toggle switch component with multiple sizes (xs, sm, md, lg, l). Built on Headless UI Switch with smooth animations.', + component: 'Toggle switch built on Base UI with CVA variants, Figma-aligned design tokens, loading spinner, and skeleton placeholder. Import `Switch` for the toggle and `SwitchSkeleton` from `./skeleton` for loading placeholders.', }, }, }, @@ -20,7 +21,7 @@ const meta = { argTypes: { size: { control: 'select', - options: ['xs', 'sm', 'md', 'lg', 'l'], + options: ['xs', 'sm', 'md', 'lg'], description: 'Switch size', }, value: { @@ -31,36 +32,33 @@ const meta = { control: 'boolean', description: 'Disabled state', }, + loading: { + control: 'boolean', + description: 'Loading state with spinner (md/lg only)', + }, }, } satisfies Meta export default meta type Story = StoryObj -// Interactive demo wrapper const SwitchDemo = (args: any) => { const [enabled, setEnabled] = useState(args.value ?? false) return ( -
-
- { - setEnabled(value) - console.log('Switch toggled:', value) - }} - /> - - {enabled ? 'On' : 'Off'} - -
+
+ + + {enabled ? 'On' : 'Off'} +
) } -// Default state (off) export const Default: Story = { render: args => , args: { @@ -70,7 +68,6 @@ export const Default: Story = { }, } -// Default on export const DefaultOn: Story = { render: args => , args: { @@ -80,7 +77,6 @@ export const DefaultOn: Story = { }, } -// Disabled off export const DisabledOff: Story = { render: args => , args: { @@ -90,7 +86,6 @@ export const DisabledOff: Story = { }, } -// Disabled on export const DisabledOn: Story = { render: args => , args: { @@ -100,47 +95,90 @@ export const DisabledOn: Story = { }, } -// Size variations +const AllStatesDemo = () => { + const sizes = ['xs', 'sm', 'md', 'lg'] as const + + return ( +
+ + + + + + + + + + + + {sizes.map(size => ( + + + + + + + + ))} + +
SizeDefaultDisabledLoadingSkeleton
{size} +
+ {}} /> + {}} /> +
+
+
+ + +
+
+
+ + +
+
+ +
+
+ ) +} + +export const AllStates: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Complete variant matrix: all sizes × all states, matching Figma design spec (node 2144:1210).', + }, + }, + }, +} + const SizeComparisonDemo = () => { const [states, setStates] = useState({ xs: false, sm: false, md: true, lg: true, - l: false, }) return ( -
-
-
- setStates({ ...states, xs: v })} /> - Extra Small (xs) -
+
+
+ setStates({ ...states, xs: v })} /> + Extra Small (xs) — 14×10
-
-
- setStates({ ...states, sm: v })} /> - Small (sm) -
+
+ setStates({ ...states, sm: v })} /> + Small (sm) — 20×12
-
-
- setStates({ ...states, md: v })} /> - Medium (md) -
+
+ setStates({ ...states, md: v })} /> + Regular (md) — 28×16
-
-
- setStates({ ...states, l: v })} /> - Large (l) -
-
-
-
- setStates({ ...states, lg: v })} /> - Extra Large (lg) -
+
+ setStates({ ...states, lg: v })} /> + Large (lg) — 36×20
) @@ -150,488 +188,168 @@ export const SizeComparison: Story = { render: () => , } -// With labels -const WithLabelsDemo = () => { - const [enabled, setEnabled] = useState(true) +const LoadingDemo = () => { + const [loading, setLoading] = useState(true) return ( -
-
-
-
Email Notifications
-
Receive email updates about your account
+
+ +
+
+ + Large unchecked +
+
+ + Large checked +
+
+ + Regular unchecked +
+
+ + Regular checked +
+
+ + Small (no spinner) +
+
+ + Extra Small (no spinner) +
+
+
+ ) +} + +export const Loading: Story = { + render: () => , + 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.', + }, + }, + }, +} + +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +const MutationLoadingDemo = () => { + const [enabled, setEnabled] = useState(false) + const [requestCount, setRequestCount] = useState(0) + const [isPending, startTransition] = useTransition() + + const handleChange = (nextValue: boolean) => { + if (isPending) + return + + startTransition(async () => { + setRequestCount(current => current + 1) + await wait(1200) + setEnabled(nextValue) + }) + } + + return ( +
+
+

Mutation Loading Guard

+

+ Click once to start a simulated mutate call. While the request is pending, the switch enters + {' '} + loading + {' '} + and rejects duplicate clicks. +

+
+ +
+
+

Enable Auto Retry

+

+ {isPending ? 'Saving…' : enabled ? 'Saved as on' : 'Saved as off'} +

-
- ) -} -export const WithLabels: Story = { - render: () => , -} - -// Real-world example - Settings panel -const SettingsPanelDemo = () => { - const [settings, setSettings] = useState({ - notifications: true, - autoSave: true, - darkMode: false, - analytics: false, - emailUpdates: true, - }) - - const updateSetting = (key: string, value: boolean) => { - setSettings({ ...settings, [key]: value }) - } - - return ( -
-

Application Settings

-
-
-
-
Push Notifications
-
Receive push notifications on your device
-
- updateSetting('notifications', v)} - /> +
+
+
Committed Value
+
{enabled ? 'On' : 'Off'}
- -
-
-
Auto-Save
-
Automatically save changes as you work
-
- updateSetting('autoSave', v)} - /> -
- -
-
-
Dark Mode
-
Use dark theme for the interface
-
- updateSetting('darkMode', v)} - /> -
- -
-
-
Analytics
-
Help us improve by sharing usage data
-
- updateSetting('analytics', v)} - /> -
- -
-
-
Email Updates
-
Receive product updates via email
-
- updateSetting('emailUpdates', v)} - /> +
+
Mutate Count
+
{requestCount}
) } -export const SettingsPanel: Story = { - render: () => , +export const MutationLoadingGuard: Story = { + render: () => , + 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.', + }, + }, + }, } -// Real-world example - Privacy controls -const PrivacyControlsDemo = () => { - const [privacy, setPrivacy] = useState({ - profilePublic: false, - showEmail: false, - allowMessages: true, - shareActivity: false, - }) - - return ( -
-

Privacy Settings

-

Control who can see your information

-
-
-
-
Public Profile
-
Make your profile visible to everyone
-
- setPrivacy({ ...privacy, profilePublic: v })} - /> -
- -
-
-
Show Email Address
-
Display your email on your profile
-
- setPrivacy({ ...privacy, showEmail: v })} - /> -
- -
-
-
Allow Direct Messages
-
Let others send you private messages
-
- setPrivacy({ ...privacy, allowMessages: v })} - /> -
- -
-
-
Share Activity
-
Show your recent activity to connections
-
- setPrivacy({ ...privacy, shareActivity: v })} - /> -
-
+const SkeletonDemo = () => ( +
+
+ + Extra Small skeleton
- ) -} - -export const PrivacyControls: Story = { - render: () => , -} - -// Real-world example - Feature toggles -const FeatureTogglesDemo = () => { - const [features, setFeatures] = useState({ - betaFeatures: false, - experimentalUI: false, - advancedMode: true, - developerTools: false, - }) - - return ( -
-

Feature Flags

-
-
-
- 🧪 -
-
Beta Features
-
Access experimental functionality
-
-
- setFeatures({ ...features, betaFeatures: v })} - /> -
- -
-
- 🎨 -
-
Experimental UI
-
Try the new interface design
-
-
- setFeatures({ ...features, experimentalUI: v })} - /> -
- -
-
- -
-
Advanced Mode
-
Show advanced configuration options
-
-
- setFeatures({ ...features, advancedMode: v })} - /> -
- -
-
- 🔧 -
-
Developer Tools
-
Enable debugging and inspection tools
-
-
- setFeatures({ ...features, developerTools: v })} - /> -
-
+
+ + Small skeleton
- ) -} - -export const FeatureToggles: Story = { - render: () => , -} - -// Real-world example - Notification preferences -const NotificationPreferencesDemo = () => { - const [notifications, setNotifications] = useState({ - email: true, - push: true, - sms: false, - desktop: true, - }) - - const allEnabled = Object.values(notifications).every(v => v) - const someEnabled = Object.values(notifications).some(v => v) - - return ( -
-
-

Notification Channels

-
- {allEnabled ? 'All enabled' : someEnabled ? 'Some enabled' : 'All disabled'} -
-
-
-
-
- 📧 -
-
Email
-
Receive notifications via email
-
-
- setNotifications({ ...notifications, email: v })} - /> -
- -
-
- 🔔 -
-
Push Notifications
-
Mobile and browser push notifications
-
-
- setNotifications({ ...notifications, push: v })} - /> -
- -
-
- 💬 -
-
SMS Messages
-
Receive text message notifications
-
-
- setNotifications({ ...notifications, sms: v })} - /> -
- -
-
- 💻 -
-
Desktop Alerts
-
Show desktop notification popups
-
-
- setNotifications({ ...notifications, desktop: v })} - /> -
-
+
+ + Regular skeleton
- ) -} - -export const NotificationPreferences: Story = { - render: () => , -} - -// Real-world example - API access control -const APIAccessControlDemo = () => { - const [access, setAccess] = useState({ - readAccess: true, - writeAccess: true, - deleteAccess: false, - adminAccess: false, - }) - - return ( -
-

API Permissions

-

Configure access levels for API key

-
-
-
-
- - {' '} - Read Access -
-
View resources and data
-
- setAccess({ ...access, readAccess: v })} - /> -
- -
-
-
- - {' '} - Write Access -
-
Create and update resources
-
- setAccess({ ...access, writeAccess: v })} - /> -
- -
-
-
- 🗑 - {' '} - Delete Access -
-
Remove resources permanently
-
- setAccess({ ...access, deleteAccess: v })} - /> -
- -
-
-
- - {' '} - Admin Access -
-
Full administrative privileges
-
- setAccess({ ...access, adminAccess: v })} - /> -
-
+
+ + Large skeleton
- ) +
+) + +export const Skeleton: Story = { + render: () => , + parameters: { + docs: { + description: { + story: '`SwitchSkeleton` renders a non-interactive placeholder with `bg-text-quaternary opacity-20`. Imported separately from `./skeleton`.', + }, + }, + }, } -export const APIAccessControl: Story = { - render: () => , -} - -// Compact list with switches -const CompactListDemo = () => { - const [items, setItems] = useState([ - { id: 1, name: 'Feature A', enabled: true }, - { id: 2, name: 'Feature B', enabled: false }, - { id: 3, name: 'Feature C', enabled: true }, - { id: 4, name: 'Feature D', enabled: false }, - { id: 5, name: 'Feature E', enabled: true }, - ]) - - const toggleItem = (id: number) => { - setItems(items.map(item => - item.id === id ? { ...item, enabled: !item.enabled } : item, - )) - } - - return ( -
-

Quick Toggles

-
- {items.map(item => ( -
- {item.name} - toggleItem(item.id)} - /> -
- ))} -
-
- ) -} - -export const CompactList: Story = { - render: () => , -} - -// Interactive playground export const Playground: Story = { render: args => , args: { size: 'md', value: false, disabled: false, + loading: false, }, } diff --git a/web/app/components/base/switch/index.tsx b/web/app/components/base/switch/index.tsx index 20ac963950..b3321827b9 100644 --- a/web/app/components/base/switch/index.tsx +++ b/web/app/components/base/switch/index.tsx @@ -1,70 +1,125 @@ 'use client' -import { Switch as OriginalSwitch } from '@headlessui/react' + +import type { VariantProps } from 'class-variance-authority' +import { Switch as BaseSwitch } from '@base-ui/react/switch' +import { cva } from 'class-variance-authority' import * as React from 'react' import { cn } from '@/utils/classnames' +const switchRootStateClassName = 'bg-components-toggle-bg-unchecked hover:bg-components-toggle-bg-unchecked-hover data-[checked]:bg-components-toggle-bg data-[checked]:hover:bg-components-toggle-bg-hover data-[disabled]:cursor-not-allowed data-[disabled]:bg-components-toggle-bg-unchecked-disabled data-[disabled]:hover:bg-components-toggle-bg-unchecked-disabled data-[disabled]:data-[checked]:bg-components-toggle-bg-disabled data-[disabled]:data-[checked]:hover:bg-components-toggle-bg-disabled' + +const switchRootVariants = cva( + `group relative inline-flex shrink-0 cursor-pointer touch-manipulation items-center transition-colors duration-200 ease-in-out focus-visible:ring-2 focus-visible:ring-components-toggle-bg motion-reduce:transition-none ${switchRootStateClassName}`, + { + variants: { + size: { + xs: 'h-2.5 w-3.5 rounded-[2px] p-0.5', + sm: 'h-3 w-5 rounded-[3.5px] p-0.5', + md: 'h-4 w-7 rounded-[5px] p-0.5', + lg: 'h-5 w-9 rounded-[6px] p-[3px]', + }, + }, + defaultVariants: { + size: 'md', + }, + }, +) + +const switchThumbVariants = cva( + 'block bg-components-toggle-knob shadow-sm transition-transform duration-200 ease-in-out group-hover:bg-components-toggle-knob-hover group-hover:shadow-md group-data-[disabled]:bg-components-toggle-knob-disabled group-data-[disabled]:shadow-none motion-reduce:transition-none', + { + variants: { + size: { + xs: 'h-1.5 w-1 rounded-[1px] data-[checked]:translate-x-1.5', + sm: 'h-2 w-[7px] rounded-[2px] data-[checked]:translate-x-[9px]', + md: 'h-3 w-2.5 rounded-[3px] data-[checked]:translate-x-[14px]', + lg: 'size-3.5 rounded-[4px] data-[checked]:translate-x-4', + }, + }, + defaultVariants: { + size: 'md', + }, + }, +) + +export type SwitchSize = NonNullable['size']> + +const spinnerSizeConfig: Partial> = { + 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)]', + }, +} + type SwitchProps = { 'value': boolean 'onChange'?: (value: boolean) => void - 'size'?: 'xs' | 'sm' | 'md' | 'lg' | 'l' + 'size'?: SwitchSize 'disabled'?: boolean + 'loading'?: boolean 'className'?: string + 'aria-label'?: string + 'aria-labelledby'?: string 'data-testid'?: string } -const Switch = ( - { - ref: propRef, - value, - onChange, - size = 'md', - disabled = false, - className, - 'data-testid': dataTestid, - }: SwitchProps & { - ref?: React.RefObject - }, -) => { - const wrapStyle = { - lg: 'h-6 w-11', - l: 'h-5 w-9', - md: 'h-4 w-7', - sm: 'h-3 w-5', - xs: 'h-2.5 w-3.5', - } +const Switch = ({ + ref, + value, + onChange, + size = 'md', + disabled = false, + loading = false, + className, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'data-testid': dataTestid, +}: SwitchProps & { + ref?: React.Ref +}) => { + const isDisabled = disabled || loading + const spinner = loading ? spinnerSizeConfig[size] : undefined - const circleStyle = { - lg: 'h-5 w-5', - l: 'h-4 w-4', - md: 'h-3 w-3', - sm: 'h-2 w-2', - xs: 'h-1.5 w-1', - } - - const translateLeft = { - lg: 'translate-x-5', - l: 'translate-x-4', - md: 'translate-x-3', - sm: 'translate-x-2', - xs: 'translate-x-1.5', - } return ( - { - if (disabled) - return - onChange?.(checked) - }} - className={cn(wrapStyle[size], value ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked', 'relative inline-flex shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out', disabled ? '!cursor-not-allowed !opacity-50' : '', size === 'xs' && 'rounded-sm', className)} + onCheckedChange={checked => onChange?.(checked)} + disabled={isDisabled} + aria-busy={loading || undefined} + aria-label={ariaLabel} + aria-labelledby={ariaLabelledBy} + className={cn(switchRootVariants({ size }), className)} data-testid={dataTestid} > - + {spinner + ? ( + + ) + : null} + ) } diff --git a/web/app/components/base/switch/skeleton.tsx b/web/app/components/base/switch/skeleton.tsx new file mode 100644 index 0000000000..f62bcbc9f3 --- /dev/null +++ b/web/app/components/base/switch/skeleton.tsx @@ -0,0 +1,41 @@ +import type { SwitchSize } from './index' +import { cva } from 'class-variance-authority' +import { cn } from '@/utils/classnames' + +const skeletonVariants = cva( + 'bg-text-quaternary opacity-20', + { + variants: { + size: { + xs: 'h-2.5 w-3.5 rounded-[2px]', + sm: 'h-3 w-5 rounded-[3.5px]', + md: 'h-4 w-7 rounded-[5px]', + lg: 'h-5 w-9 rounded-[6px]', + }, + }, + defaultVariants: { + size: 'md', + }, + }, +) + +type SwitchSkeletonProps = { + 'size'?: SwitchSize + 'className'?: string + 'data-testid'?: string +} + +export function SwitchSkeleton({ + size = 'md', + className, + 'data-testid': dataTestid, +}: SwitchSkeletonProps) { + return ( +
+ ) +} + +SwitchSkeleton.displayName = 'SwitchSkeleton' diff --git a/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx b/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx index 92cbdf0e63..b2a1ae3bf5 100644 --- a/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx +++ b/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx @@ -23,7 +23,7 @@ const PlanRangeSwitcher: FC = ({ return (
{ onChange(v ? PlanRange.yearly : PlanRange.monthly) diff --git a/web/app/components/custom/custom-web-app-brand/index.tsx b/web/app/components/custom/custom-web-app-brand/index.tsx index e6f9a3837b..fa79c9540a 100644 --- a/web/app/components/custom/custom-web-app-brand/index.tsx +++ b/web/app/components/custom/custom-web-app-brand/index.tsx @@ -119,7 +119,7 @@ const CustomWebAppBrand = () => {
{t('webapp.removeBrand', { ns: 'custom' })} { it('should render disabled switch when embeddingAvailable is false in list scene', () => { render() - // Switch component uses opacity-50 class when disabled - const disabledSwitch = document.querySelector('.\\!opacity-50') - expect(disabledSwitch).toBeInTheDocument() + const disabledSwitch = screen.getByRole('switch') + expect(disabledSwitch).toHaveAttribute('aria-disabled', 'true') }) }) diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx index f0edbb3ebc..00cf9769df 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx @@ -206,7 +206,7 @@ describe('SegmentCard', () => { ) const switchElement = screen.getByRole('switch') - expect(switchElement).toHaveClass('!cursor-not-allowed') + expect(switchElement).toHaveAttribute('aria-disabled', 'true') }) it('should show action buttons when embeddingAvailable is true', () => { @@ -792,9 +792,8 @@ describe('SegmentCard', () => { />, ) - // The Switch component uses CSS classes for disabled state, not the native disabled attribute const switchElement = screen.getByRole('switch') - expect(switchElement).toHaveClass('!cursor-not-allowed', '!opacity-50') + expect(switchElement).toHaveAttribute('aria-disabled', 'true') }) it('should handle zero word count', () => { @@ -877,7 +876,7 @@ describe('SegmentCard', () => { ) const switchElement = screen.getByRole('switch') - expect(switchElement).toHaveClass('bg-components-toggle-bg') + expect(switchElement).toHaveAttribute('aria-checked', 'true') }) it('should render real Switch component with unchecked state', () => { @@ -893,7 +892,7 @@ describe('SegmentCard', () => { ) const switchElement = screen.getByRole('switch') - expect(switchElement).toHaveClass('bg-components-toggle-bg-unchecked') + expect(switchElement).toHaveAttribute('aria-checked', 'false') }) it('should render real SegmentIndexTag with position formatting', () => { diff --git a/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx index ce31bdc62f..8e3cd721e2 100644 --- a/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx @@ -169,9 +169,8 @@ describe('StatusItem', () => { datasetId="dataset-1" />, ) - const switchElement = document.querySelector('[role="switch"]') - // Switch component uses opacity-50 and cursor-not-allowed when disabled - expect(switchElement).toHaveClass('!opacity-50') + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveAttribute('aria-disabled', 'true') }) it('should render switch as disabled when embedding (queuing status)', () => { @@ -187,9 +186,8 @@ describe('StatusItem', () => { datasetId="dataset-1" />, ) - const switchElement = document.querySelector('[role="switch"]') - // Switch component uses opacity-50 and cursor-not-allowed when disabled - expect(switchElement).toHaveClass('!opacity-50') + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveAttribute('aria-disabled', 'true') }) it('should render switch as disabled when embedding (indexing status)', () => { @@ -205,9 +203,8 @@ describe('StatusItem', () => { datasetId="dataset-1" />, ) - const switchElement = document.querySelector('[role="switch"]') - // Switch component uses opacity-50 and cursor-not-allowed when disabled - expect(switchElement).toHaveClass('!opacity-50') + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveAttribute('aria-disabled', 'true') }) it('should render switch as disabled when embedding (paused status)', () => { @@ -223,9 +220,8 @@ describe('StatusItem', () => { datasetId="dataset-1" />, ) - const switchElement = document.querySelector('[role="switch"]') - // Switch component uses opacity-50 and cursor-not-allowed when disabled - expect(switchElement).toHaveClass('!opacity-50') + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveAttribute('aria-disabled', 'true') }) }) diff --git a/web/app/components/datasets/extra-info/__tests__/index.spec.tsx b/web/app/components/datasets/extra-info/__tests__/index.spec.tsx index f4e651d3c5..4a8d89e9fb 100644 --- a/web/app/components/datasets/extra-info/__tests__/index.spec.tsx +++ b/web/app/components/datasets/extra-info/__tests__/index.spec.tsx @@ -674,9 +674,7 @@ describe('ApiAccessCard', () => { ) const switchButton = screen.getByRole('switch') - // Headless UI Switch uses CSS classes for disabled state - expect(switchButton).toHaveClass('!cursor-not-allowed') - expect(switchButton).toHaveClass('!opacity-50') + expect(switchButton).toHaveAttribute('aria-disabled', 'true') }) it('should enable switch when user is workspace manager', () => { @@ -689,8 +687,7 @@ describe('ApiAccessCard', () => { ) const switchButton = screen.getByRole('switch') - expect(switchButton).not.toHaveClass('!cursor-not-allowed') - expect(switchButton).not.toHaveClass('!opacity-50') + expect(switchButton).not.toHaveAttribute('aria-disabled', 'true') }) }) diff --git a/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx b/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx index 3fa542f002..3f9eadc705 100644 --- a/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx +++ b/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx @@ -155,8 +155,7 @@ describe('Card (API Access)', () => { const switchButton = screen.getByRole('switch') expect(switchButton).toHaveAttribute('aria-checked', 'true') - // Headless UI Switch uses CSS classes for disabled state, not the disabled attribute - expect(switchButton).toHaveClass('!cursor-not-allowed', '!opacity-50') + expect(switchButton).toHaveAttribute('aria-disabled', 'true') }) it('should enable switch when user is workspace manager', () => { @@ -164,7 +163,7 @@ describe('Card (API Access)', () => { render() const switchButton = screen.getByRole('switch') - expect(switchButton).not.toBeDisabled() + expect(switchButton).not.toHaveAttribute('aria-disabled', 'true') }) }) diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx index 89ddb76694..8fdbb53507 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx @@ -325,18 +325,12 @@ describe('DatasetMetadataDrawer', () => { fireEvent.change(inputs[0], { target: { value: 'changed_name' } }) // Find and click cancel button - const cancelBtns = screen.getAllByText(/cancel/i) - const cancelBtn = cancelBtns.find(btn => - !btn.closest('button')?.classList.contains('btn-primary'), - ) - if (cancelBtn) - fireEvent.click(cancelBtn) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - // Verify input resets or modal closes + // Verify rename modal closes while drawer stays open await waitFor(() => { - const currentInputs = document.querySelectorAll('input') - // Either no inputs (modal closed) or value reset - expect(currentInputs.length === 0 || currentInputs[0].value !== 'changed_name').toBe(true) + expect(screen.queryByRole('dialog', { name: 'dataset.metadata.datasetMetadata.rename' })).not.toBeInTheDocument() + expect(screen.getAllByRole('dialog')).toHaveLength(1) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.spec.tsx index ee3bc4b159..a1ab77b16f 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.spec.tsx @@ -231,7 +231,7 @@ describe('ModelListItem', () => { // Assert - ConfigModel should not render because status is not active/disabled expect(screen.queryByRole('button', { name: 'modify load balancing' })).not.toBeInTheDocument() const statusSwitch = screen.getByRole('switch') - expect(statusSwitch).toHaveClass('!cursor-not-allowed') + expect(statusSwitch).toHaveAttribute('aria-disabled', 'true') fireEvent.click(statusSwitch) expect(statusSwitch).toHaveAttribute('aria-checked', 'false') expect(enableModel).not.toHaveBeenCalled() diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.spec.tsx index eb0a98e9dc..9640736fdd 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.spec.tsx @@ -193,7 +193,7 @@ describe('ModelLoadBalancingConfigs', () => { render() const mainSwitch = screen.getByTestId('load-balancing-switch-main') - expect(mainSwitch).toHaveClass('!cursor-not-allowed') + expect(mainSwitch).toHaveAttribute('aria-disabled', 'true') // Clicking should not trigger any changes (effectively disabled) await user.click(mainSwitch) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx index 1b1acd90fc..6c219c4e5a 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx @@ -164,7 +164,7 @@ const ModelLoadBalancingConfigs = ({ withSwitch && ( toggleModalBalancing(value)} diff --git a/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx index 43ce810217..d408b3092e 100644 --- a/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx @@ -213,8 +213,7 @@ describe('MCPServiceCard', () => { render(, { wrapper: createWrapper() }) const switchElement = screen.getByRole('switch') - expect(switchElement.className).toContain('!cursor-not-allowed') - expect(switchElement.className).toContain('!opacity-50') + expect(switchElement.className).toContain('cursor-not-allowed') }) }) @@ -355,8 +354,7 @@ describe('MCPServiceCard', () => { render(, { wrapper: createWrapper() }) const switchElement = screen.getByRole('switch') - expect(switchElement.className).toContain('!cursor-not-allowed') - expect(switchElement.className).toContain('!opacity-50') + expect(switchElement.className).toContain('cursor-not-allowed') }) }) @@ -371,8 +369,7 @@ describe('MCPServiceCard', () => { render(, { wrapper: createWrapper() }) const switchElement = screen.getByRole('switch') - expect(switchElement.className).toContain('!cursor-not-allowed') - expect(switchElement.className).toContain('!opacity-50') + expect(switchElement.className).toContain('cursor-not-allowed') }) }) @@ -428,11 +425,10 @@ describe('MCPServiceCard', () => { }) describe('Accessibility', () => { - it('should have an accessible switch with button type', () => { + it('should have an accessible switch element', () => { render(, { wrapper: createWrapper() }) - const switchElement = screen.getByRole('switch') - expect(switchElement).toHaveAttribute('type', 'button') + expect(screen.getByRole('switch')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx index b3fb0bebbd..5a4113848a 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx @@ -174,7 +174,7 @@ const AddExtractParameter: FC = ({ <>
{t(`${i18nPrefix}.addExtractParameterContent.requiredContent`, { ns: 'workflow' })}
- +
diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx index 321e8a084c..9b2fae349c 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx @@ -79,7 +79,7 @@ const Operator = ({ >
{t('nodes.note.editor.showAuthor', { ns: 'workflow' })}
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 249955a304..edd6a2ef4c 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -2772,9 +2772,6 @@ } }, "app/components/base/switch/index.stories.tsx": { - "no-console": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 }