mirror of
https://github.com/langgenius/dify.git
synced 2026-03-26 05:29:50 +08:00
refactor(switch): Base UI migration with loading/skeleton variants (#33345)
Signed-off-by: yyh <yuanyouhuilyz@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
b44b37518a
commit
c43307dae1
@ -179,7 +179,7 @@ const Tools = () => {
|
||||
</div>
|
||||
<div className="ml-2 mr-3 hidden h-3.5 w-[1px] bg-gray-200 group-hover:block" />
|
||||
<Switch
|
||||
size="l"
|
||||
size="lg"
|
||||
value={item.enabled ?? false}
|
||||
onChange={(enabled: boolean) => handleSaveExternalDataToolModal({ ...item, enabled }, index)}
|
||||
/>
|
||||
|
||||
@ -37,7 +37,7 @@ const ModerationContent: FC<ModerationContentProps> = ({
|
||||
)
|
||||
}
|
||||
<Switch
|
||||
size="l"
|
||||
size="lg"
|
||||
value={config.enabled}
|
||||
onChange={v => handleConfigChange('enabled', v)}
|
||||
/>
|
||||
|
||||
@ -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(<Switch value={true} />)
|
||||
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(<Switch value={false} disabled onChange={onChange} />)
|
||||
|
||||
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(<Switch value={false} size="xs" />)
|
||||
// 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(<Switch value={false} size="sm" />)
|
||||
expect(switchElement).toHaveClass('h-3', 'w-5')
|
||||
@ -72,11 +76,8 @@ describe('Switch', () => {
|
||||
rerender(<Switch value={false} size="md" />)
|
||||
expect(switchElement).toHaveClass('h-4', 'w-7')
|
||||
|
||||
rerender(<Switch value={false} size="l" />)
|
||||
expect(switchElement).toHaveClass('h-5', 'w-9')
|
||||
|
||||
rerender(<Switch value={false} size="lg" />)
|
||||
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(<Switch value={false} />)
|
||||
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(<Switch value={true} />)
|
||||
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(<Switch value={false} disabled />)
|
||||
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(<Switch value={true} disabled />)
|
||||
expect(switchElement).toHaveAttribute('data-disabled', '')
|
||||
expect(switchElement).toHaveAttribute('data-checked', '')
|
||||
})
|
||||
|
||||
it('should have focus-visible ring styles', () => {
|
||||
render(<Switch value={false} />)
|
||||
const switchElement = screen.getByRole('switch')
|
||||
expect(switchElement).toHaveClass('focus-visible:ring-2')
|
||||
})
|
||||
|
||||
it('should respect prefers-reduced-motion', () => {
|
||||
render(<Switch value={false} />)
|
||||
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(<Switch value={false} loading onChange={onChange} />)
|
||||
|
||||
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(<Switch value={false} loading size="md" />)
|
||||
expect(container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
|
||||
|
||||
rerender(<Switch value={false} loading size="lg" />)
|
||||
expect(container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show spinner for xs and sm sizes', () => {
|
||||
const { rerender, container } = render(<Switch value={false} loading size="xs" />)
|
||||
expect(container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<Switch value={false} loading size="sm" />)
|
||||
expect(container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply disabled data-state hooks when loading', () => {
|
||||
const { rerender } = render(<Switch value={false} loading />)
|
||||
const switchElement = screen.getByRole('switch')
|
||||
|
||||
expect(switchElement).toHaveAttribute('data-disabled', '')
|
||||
|
||||
rerender(<Switch value={true} loading />)
|
||||
expect(switchElement).toHaveAttribute('data-disabled', '')
|
||||
expect(switchElement).toHaveAttribute('data-checked', '')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SwitchSkeleton', () => {
|
||||
it('should render a plain div without switch role', () => {
|
||||
render(<SwitchSkeleton data-testid="skeleton-switch" />)
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('skeleton-switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply skeleton styles', () => {
|
||||
render(<SwitchSkeleton data-testid="skeleton-switch" />)
|
||||
const el = screen.getByTestId('skeleton-switch')
|
||||
expect(el).toHaveClass('bg-text-quaternary', 'opacity-20')
|
||||
})
|
||||
|
||||
it('should apply correct skeleton size classes', () => {
|
||||
const { rerender } = render(<SwitchSkeleton size="xs" data-testid="s" />)
|
||||
const el = screen.getByTestId('s')
|
||||
expect(el).toHaveClass('h-2.5', 'w-3.5', 'rounded-[2px]')
|
||||
|
||||
rerender(<SwitchSkeleton size="lg" data-testid="s" />)
|
||||
expect(el).toHaveClass('h-5', 'w-9', 'rounded-[6px]')
|
||||
})
|
||||
|
||||
it('should apply custom className to skeleton', () => {
|
||||
render(<SwitchSkeleton className="custom-class" data-testid="s" />)
|
||||
expect(screen.getByTestId('s')).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<typeof Switch>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const SwitchDemo = (args: any) => {
|
||||
const [enabled, setEnabled] = useState(args.value ?? false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
{...args}
|
||||
value={enabled}
|
||||
onChange={(value) => {
|
||||
setEnabled(value)
|
||||
console.log('Switch toggled:', value)
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{enabled ? 'On' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Switch
|
||||
{...args}
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{enabled ? 'On' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state (off)
|
||||
export const Default: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
@ -70,7 +68,6 @@ export const Default: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
// Default on
|
||||
export const DefaultOn: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
@ -80,7 +77,6 @@ export const DefaultOn: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled off
|
||||
export const DisabledOff: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
@ -90,7 +86,6 @@ export const DisabledOff: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled on
|
||||
export const DisabledOn: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
@ -100,47 +95,90 @@ export const DisabledOn: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
// Size variations
|
||||
const AllStatesDemo = () => {
|
||||
const sizes = ['xs', 'sm', 'md', 'lg'] as const
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="space-y-6">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500">
|
||||
<th className="pb-3 font-medium">Size</th>
|
||||
<th className="pb-3 font-medium">Default</th>
|
||||
<th className="pb-3 font-medium">Disabled</th>
|
||||
<th className="pb-3 font-medium">Loading</th>
|
||||
<th className="pb-3 font-medium">Skeleton</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sizes.map(size => (
|
||||
<tr key={size} className="border-t border-gray-100">
|
||||
<td className="py-3 font-medium text-gray-900">{size}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-2">
|
||||
<Switch size={size} value={false} onChange={() => {}} />
|
||||
<Switch size={size} value={true} onChange={() => {}} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-2">
|
||||
<Switch size={size} value={false} disabled />
|
||||
<Switch size={size} value={true} disabled />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-2">
|
||||
<Switch size={size} value={false} loading />
|
||||
<Switch size={size} value={true} loading />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<SwitchSkeleton size={size} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => <AllStatesDemo />,
|
||||
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 (
|
||||
<div style={{ width: '400px' }} className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="xs" value={states.xs} onChange={v => setStates({ ...states, xs: v })} />
|
||||
<span className="text-sm text-gray-700">Extra Small (xs)</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="xs" value={states.xs} onChange={v => setStates({ ...states, xs: v })} />
|
||||
<span className="text-sm text-gray-700">Extra Small (xs) — 14×10</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="sm" value={states.sm} onChange={v => setStates({ ...states, sm: v })} />
|
||||
<span className="text-sm text-gray-700">Small (sm)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="sm" value={states.sm} onChange={v => setStates({ ...states, sm: v })} />
|
||||
<span className="text-sm text-gray-700">Small (sm) — 20×12</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" value={states.md} onChange={v => setStates({ ...states, md: v })} />
|
||||
<span className="text-sm text-gray-700">Medium (md)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" value={states.md} onChange={v => setStates({ ...states, md: v })} />
|
||||
<span className="text-sm text-gray-700">Regular (md) — 28×16</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="l" value={states.l} onChange={v => setStates({ ...states, l: v })} />
|
||||
<span className="text-sm text-gray-700">Large (l)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" value={states.lg} onChange={v => setStates({ ...states, lg: v })} />
|
||||
<span className="text-sm text-gray-700">Extra Large (lg)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" value={states.lg} onChange={v => setStates({ ...states, lg: v })} />
|
||||
<span className="text-sm text-gray-700">Large (lg) — 36×20</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -150,488 +188,168 @@ export const SizeComparison: Story = {
|
||||
render: () => <SizeComparisonDemo />,
|
||||
}
|
||||
|
||||
// With labels
|
||||
const WithLabelsDemo = () => {
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const LoadingDemo = () => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Email Notifications</div>
|
||||
<div className="text-xs text-gray-500">Receive email updates about your account</div>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<button
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
onClick={() => setLoading(!loading)}
|
||||
>
|
||||
{loading ? 'Stop Loading' : 'Start Loading'}
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" value={false} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Large unchecked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" value={true} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Large checked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" value={false} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Regular unchecked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" value={true} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Regular checked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="sm" value={false} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Small (no spinner)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="xs" value={false} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Extra Small (no spinner)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => <LoadingDemo />,
|
||||
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 (
|
||||
<div className="w-[340px] space-y-4 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-text-primary">Mutation Loading Guard</p>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
Click once to start a simulated mutate call. While the request is pending, the switch enters
|
||||
{' '}
|
||||
<code className="rounded bg-state-base-hover px-1 py-0.5 text-[11px]">loading</code>
|
||||
{' '}
|
||||
and rejects duplicate clicks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl border border-components-panel-border-subtle bg-background-default-dodge px-3 py-2 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-text-primary">Enable Auto Retry</p>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{isPending ? 'Saving…' : enabled ? 'Saved as on' : 'Saved as off'}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
size="lg"
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
loading={isPending}
|
||||
onChange={handleChange}
|
||||
aria-label="Enable Auto Retry"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithLabels: Story = {
|
||||
render: () => <WithLabelsDemo />,
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Application Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Push Notifications</div>
|
||||
<div className="text-xs text-gray-500">Receive push notifications on your device</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={settings.notifications}
|
||||
onChange={v => updateSetting('notifications', v)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-text-tertiary">
|
||||
<div className="rounded-lg bg-state-base-hover px-3 py-2">
|
||||
<div className="font-medium text-text-secondary">Committed Value</div>
|
||||
<div>{enabled ? 'On' : 'Off'}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Auto-Save</div>
|
||||
<div className="text-xs text-gray-500">Automatically save changes as you work</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={settings.autoSave}
|
||||
onChange={v => updateSetting('autoSave', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Dark Mode</div>
|
||||
<div className="text-xs text-gray-500">Use dark theme for the interface</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={settings.darkMode}
|
||||
onChange={v => updateSetting('darkMode', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Analytics</div>
|
||||
<div className="text-xs text-gray-500">Help us improve by sharing usage data</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={settings.analytics}
|
||||
onChange={v => updateSetting('analytics', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Email Updates</div>
|
||||
<div className="text-xs text-gray-500">Receive product updates via email</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={settings.emailUpdates}
|
||||
onChange={v => updateSetting('emailUpdates', v)}
|
||||
/>
|
||||
<div className="rounded-lg bg-state-base-hover px-3 py-2">
|
||||
<div className="font-medium text-text-secondary">Mutate Count</div>
|
||||
<div>{requestCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsPanel: Story = {
|
||||
render: () => <SettingsPanelDemo />,
|
||||
export const MutationLoadingGuard: Story = {
|
||||
render: () => <MutationLoadingDemo />,
|
||||
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 (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">Privacy Settings</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Control who can see your information</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Public Profile</div>
|
||||
<div className="text-xs text-gray-500">Make your profile visible to everyone</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={privacy.profilePublic}
|
||||
onChange={v => setPrivacy({ ...privacy, profilePublic: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Show Email Address</div>
|
||||
<div className="text-xs text-gray-500">Display your email on your profile</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={privacy.showEmail}
|
||||
onChange={v => setPrivacy({ ...privacy, showEmail: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Allow Direct Messages</div>
|
||||
<div className="text-xs text-gray-500">Let others send you private messages</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={privacy.allowMessages}
|
||||
onChange={v => setPrivacy({ ...privacy, allowMessages: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Share Activity</div>
|
||||
<div className="text-xs text-gray-500">Show your recent activity to connections</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={privacy.shareActivity}
|
||||
onChange={v => setPrivacy({ ...privacy, shareActivity: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
const SkeletonDemo = () => (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<SwitchSkeleton size="xs" />
|
||||
<span className="text-sm text-gray-700">Extra Small skeleton</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PrivacyControls: Story = {
|
||||
render: () => <PrivacyControlsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Feature toggles
|
||||
const FeatureTogglesDemo = () => {
|
||||
const [features, setFeatures] = useState({
|
||||
betaFeatures: false,
|
||||
experimentalUI: false,
|
||||
advancedMode: true,
|
||||
developerTools: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Feature Flags</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🧪</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Beta Features</div>
|
||||
<div className="text-xs text-gray-500">Access experimental functionality</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={features.betaFeatures}
|
||||
onChange={v => setFeatures({ ...features, betaFeatures: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🎨</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Experimental UI</div>
|
||||
<div className="text-xs text-gray-500">Try the new interface design</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={features.experimentalUI}
|
||||
onChange={v => setFeatures({ ...features, experimentalUI: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">⚡</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Advanced Mode</div>
|
||||
<div className="text-xs text-gray-500">Show advanced configuration options</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={features.advancedMode}
|
||||
onChange={v => setFeatures({ ...features, advancedMode: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🔧</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Developer Tools</div>
|
||||
<div className="text-xs text-gray-500">Enable debugging and inspection tools</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={features.developerTools}
|
||||
onChange={v => setFeatures({ ...features, developerTools: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SwitchSkeleton size="sm" />
|
||||
<span className="text-sm text-gray-700">Small skeleton</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FeatureToggles: Story = {
|
||||
render: () => <FeatureTogglesDemo />,
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Notification Channels</h3>
|
||||
<div className="text-xs text-gray-500">
|
||||
{allEnabled ? 'All enabled' : someEnabled ? 'Some enabled' : 'All disabled'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">📧</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Email</div>
|
||||
<div className="text-xs text-gray-500">Receive notifications via email</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={notifications.email}
|
||||
onChange={v => setNotifications({ ...notifications, email: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🔔</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Push Notifications</div>
|
||||
<div className="text-xs text-gray-500">Mobile and browser push notifications</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={notifications.push}
|
||||
onChange={v => setNotifications({ ...notifications, push: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">💬</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">SMS Messages</div>
|
||||
<div className="text-xs text-gray-500">Receive text message notifications</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={notifications.sms}
|
||||
onChange={v => setNotifications({ ...notifications, sms: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">💻</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Desktop Alerts</div>
|
||||
<div className="text-xs text-gray-500">Show desktop notification popups</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={notifications.desktop}
|
||||
onChange={v => setNotifications({ ...notifications, desktop: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SwitchSkeleton size="md" />
|
||||
<span className="text-sm text-gray-700">Regular skeleton</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const NotificationPreferences: Story = {
|
||||
render: () => <NotificationPreferencesDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - API access control
|
||||
const APIAccessControlDemo = () => {
|
||||
const [access, setAccess] = useState({
|
||||
readAccess: true,
|
||||
writeAccess: true,
|
||||
deleteAccess: false,
|
||||
adminAccess: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">API Permissions</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Configure access levels for API key</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg bg-green-50 p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<span className="text-green-600">✓</span>
|
||||
{' '}
|
||||
Read Access
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">View resources and data</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={access.readAccess}
|
||||
onChange={v => setAccess({ ...access, readAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-blue-50 p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<span className="text-blue-600">✎</span>
|
||||
{' '}
|
||||
Write Access
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Create and update resources</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={access.writeAccess}
|
||||
onChange={v => setAccess({ ...access, writeAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-red-50 p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<span className="text-red-600">🗑</span>
|
||||
{' '}
|
||||
Delete Access
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Remove resources permanently</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={access.deleteAccess}
|
||||
onChange={v => setAccess({ ...access, deleteAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-purple-50 p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<span className="text-purple-600">⚡</span>
|
||||
{' '}
|
||||
Admin Access
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Full administrative privileges</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
value={access.adminAccess}
|
||||
onChange={v => setAccess({ ...access, adminAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SwitchSkeleton size="lg" />
|
||||
<span className="text-sm text-gray-700">Large skeleton</span>
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Skeleton: Story = {
|
||||
render: () => <SkeletonDemo />,
|
||||
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: () => <APIAccessControlDemo />,
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Quick Toggles</h3>
|
||||
<div className="space-y-2">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-gray-700">{item.name}</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
value={item.enabled}
|
||||
onChange={() => toggleItem(item.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CompactList: Story = {
|
||||
render: () => <CompactListDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
value: false,
|
||||
disabled: false,
|
||||
loading: false,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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<VariantProps<typeof switchRootVariants>['size']>
|
||||
|
||||
const spinnerSizeConfig: Partial<Record<SwitchSize, {
|
||||
icon: string
|
||||
uncheckedPosition: string
|
||||
checkedPosition: string
|
||||
}>> = {
|
||||
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<HTMLButtonElement>
|
||||
},
|
||||
) => {
|
||||
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<HTMLElement>
|
||||
}) => {
|
||||
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 (
|
||||
<OriginalSwitch
|
||||
ref={propRef}
|
||||
<BaseSwitch.Root
|
||||
ref={ref}
|
||||
checked={value}
|
||||
onChange={(checked: boolean) => {
|
||||
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}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(circleStyle[size], value ? translateLeft[size] : 'translate-x-0', size === 'xs' && 'rounded-[1px]', 'pointer-events-none inline-block rounded-[3px] bg-components-toggle-knob shadow ring-0 transition duration-200 ease-in-out')}
|
||||
<BaseSwitch.Thumb
|
||||
className={switchThumbVariants({ size })}
|
||||
/>
|
||||
</OriginalSwitch>
|
||||
{spinner
|
||||
? (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
spinner.icon,
|
||||
value ? spinner.checkedPosition : spinner.uncheckedPosition,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i className="i-ri-loader-2-line size-full animate-spin text-text-tertiary motion-reduce:animate-none" />
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
</BaseSwitch.Root>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
41
web/app/components/base/switch/skeleton.tsx
Normal file
41
web/app/components/base/switch/skeleton.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className={cn(skeletonVariants({ size }), className)}
|
||||
data-testid={dataTestid}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
SwitchSkeleton.displayName = 'SwitchSkeleton'
|
||||
@ -23,7 +23,7 @@ const PlanRangeSwitcher: FC<PlanRangeSwitcherProps> = ({
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-x-3 pr-5">
|
||||
<Switch
|
||||
size="l"
|
||||
size="lg"
|
||||
value={value === PlanRange.yearly}
|
||||
onChange={(v) => {
|
||||
onChange(v ? PlanRange.yearly : PlanRange.monthly)
|
||||
|
||||
@ -119,7 +119,7 @@ const CustomWebAppBrand = () => {
|
||||
<div className="mb-2 flex items-center justify-between rounded-xl bg-background-section-burn p-4 text-text-primary system-md-medium">
|
||||
{t('webapp.removeBrand', { ns: 'custom' })}
|
||||
<Switch
|
||||
size="l"
|
||||
size="lg"
|
||||
value={webappBrandRemoved ?? false}
|
||||
disabled={isSandbox || !isCurrentWorkspaceManager}
|
||||
onChange={handleSwitch}
|
||||
|
||||
@ -117,9 +117,8 @@ describe('Operations', () => {
|
||||
|
||||
it('should render disabled switch when embeddingAvailable is false in list scene', () => {
|
||||
render(<Operations {...defaultProps} embeddingAvailable={false} scene="list" />)
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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(<Card apiEnabled={true} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
expect(switchButton).not.toBeDisabled()
|
||||
expect(switchButton).not.toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -193,7 +193,7 @@ describe('ModelLoadBalancingConfigs', () => {
|
||||
render(<StatefulHarness initialConfig={createDraftConfig(false)} withSwitch />)
|
||||
|
||||
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)
|
||||
|
||||
@ -164,7 +164,7 @@ const ModelLoadBalancingConfigs = ({
|
||||
withSwitch && (
|
||||
<Switch
|
||||
value={Boolean(draftConfig.enabled)}
|
||||
size="l"
|
||||
size="lg"
|
||||
className="ml-3 justify-self-end"
|
||||
disabled={!modelLoadBalancingEnabled && !draftConfig.enabled}
|
||||
onChange={value => toggleModalBalancing(value)}
|
||||
|
||||
@ -213,8 +213,7 @@ describe('MCPServiceCard', () => {
|
||||
render(<MCPServiceCard appInfo={createMockAppInfo()} />, { 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(<MCPServiceCard appInfo={createMockAppInfo()} />, { 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(<MCPServiceCard appInfo={createMockAppInfo(AppModeEnum.WORKFLOW)} />, { 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(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() })
|
||||
|
||||
const switchElement = screen.getByRole('switch')
|
||||
expect(switchElement).toHaveAttribute('type', 'button')
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -174,7 +174,7 @@ const AddExtractParameter: FC<Props> = ({
|
||||
<Field title={t(`${i18nPrefix}.addExtractParameterContent.required`, { ns: 'workflow' })}>
|
||||
<>
|
||||
<div className="mb-1.5 text-xs font-normal leading-[18px] text-text-tertiary">{t(`${i18nPrefix}.addExtractParameterContent.requiredContent`, { ns: 'workflow' })}</div>
|
||||
<Switch size="l" value={param.required ?? false} onChange={handleParamChange('required')} />
|
||||
<Switch size="lg" value={param.required ?? false} onChange={handleParamChange('required')} />
|
||||
</>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@ -79,7 +79,7 @@ const Operator = ({
|
||||
>
|
||||
<div>{t('nodes.note.editor.showAuthor', { ns: 'workflow' })}</div>
|
||||
<Switch
|
||||
size="l"
|
||||
size="lg"
|
||||
value={showAuthor}
|
||||
onChange={onShowAuthorChange}
|
||||
/>
|
||||
|
||||
@ -2772,9 +2772,6 @@
|
||||
}
|
||||
},
|
||||
"app/components/base/switch/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user