Compare commits

..

1 Commits

Author SHA1 Message Date
521545d52e fix: migrate model type enum construction 2026-05-23 18:56:48 +08:00
10 changed files with 155 additions and 245 deletions

View File

@ -795,7 +795,7 @@ class ProviderManager:
return [
{
"model": model_key[0],
"model_type": ModelType.value_of(model_key[1]),
"model_type": ModelType(model_key[1]),
"available_model_credentials": [
CredentialConfiguration(credential_id=cred.id, credential_name=cred.credential_name)
for cred in creds

View File

@ -66,7 +66,7 @@ class ModelLoadBalancingService:
raise ValueError(f"Provider {provider} does not exist.")
# Enable model load balancing
provider_configuration.enable_model_load_balancing(model=model, model_type=ModelType.value_of(model_type))
provider_configuration.enable_model_load_balancing(model=model, model_type=ModelType(model_type))
def disable_model_load_balancing(self, tenant_id: str, provider: str, model: str, model_type: str):
"""
@ -87,7 +87,7 @@ class ModelLoadBalancingService:
raise ValueError(f"Provider {provider} does not exist.")
# disable model load balancing
provider_configuration.disable_model_load_balancing(model=model, model_type=ModelType.value_of(model_type))
provider_configuration.disable_model_load_balancing(model=model, model_type=ModelType(model_type))
def get_load_balancing_configs(
self, tenant_id: str, provider: str, model: str, model_type: str, config_from: str = ""
@ -109,7 +109,7 @@ class ModelLoadBalancingService:
raise ValueError(f"Provider {provider} does not exist.")
# Convert model type to ModelType
model_type_enum = ModelType.value_of(model_type)
model_type_enum = ModelType(model_type)
# Get provider model setting
provider_model_setting = provider_configuration.get_provider_model_setting(
@ -250,7 +250,7 @@ class ModelLoadBalancingService:
raise ValueError(f"Provider {provider} does not exist.")
# Convert model type to ModelType
model_type_enum = ModelType.value_of(model_type)
model_type_enum = ModelType(model_type)
# Get load balancing configurations
load_balancing_model_config = db.session.scalar(
@ -338,7 +338,7 @@ class ModelLoadBalancingService:
raise ValueError(f"Provider {provider} does not exist.")
# Convert model type to ModelType
model_type_enum = ModelType.value_of(model_type)
model_type_enum = ModelType(model_type)
if not isinstance(configs, list):
raise ValueError("Invalid load balancing configs")
@ -524,7 +524,7 @@ class ModelLoadBalancingService:
raise ValueError(f"Provider {provider} does not exist.")
# Convert model type to ModelType
model_type_enum = ModelType.value_of(model_type)
model_type_enum = ModelType(model_type)
load_balancing_model_config = None
if config_id:

View File

@ -67,7 +67,7 @@ class ModelProviderService:
provider_responses = []
for provider_configuration in provider_configurations.values():
if model_type:
model_type_entity = ModelType.value_of(model_type)
model_type_entity = ModelType(model_type)
if model_type_entity not in provider_configuration.provider.supported_model_types:
continue
@ -269,7 +269,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
return provider_configuration.get_custom_model_credential(
model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id
model_type=ModelType(model_type), model=model, credential_id=credential_id
)
def validate_model_credentials(
@ -287,7 +287,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.validate_custom_model_credentials(
model_type=ModelType.value_of(model_type), model=model, credentials=credentials
model_type=ModelType(model_type), model=model, credentials=credentials
)
def create_model_credential(
@ -312,7 +312,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.create_custom_model_credential(
model_type=ModelType.value_of(model_type),
model_type=ModelType(model_type),
model=model,
credentials=credentials,
credential_name=credential_name,
@ -342,7 +342,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.update_custom_model_credential(
model_type=ModelType.value_of(model_type),
model_type=ModelType(model_type),
model=model,
credentials=credentials,
credential_id=credential_id,
@ -362,7 +362,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.delete_custom_model_credential(
model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id
model_type=ModelType(model_type), model=model, credential_id=credential_id
)
def switch_active_custom_model_credential(
@ -380,7 +380,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.switch_custom_model_credential(
model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id
model_type=ModelType(model_type), model=model, credential_id=credential_id
)
def add_model_credential_to_model_list(
@ -398,7 +398,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.add_model_credential_to_model(
model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id
model_type=ModelType(model_type), model=model, credential_id=credential_id
)
def remove_model(self, tenant_id: str, provider: str, model_type: str, model: str):
@ -412,7 +412,7 @@ class ModelProviderService:
:return:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.delete_custom_model(model_type=ModelType.value_of(model_type), model=model)
provider_configuration.delete_custom_model(model_type=ModelType(model_type), model=model)
def get_models_by_model_type(self, tenant_id: str, model_type: str) -> list[ProviderWithModelsResponse]:
"""
@ -426,7 +426,7 @@ class ModelProviderService:
provider_configurations = self._get_provider_manager(tenant_id).get_configurations(tenant_id)
# Get provider available models
models = provider_configurations.get_models(model_type=ModelType.value_of(model_type), only_active=True)
models = provider_configurations.get_models(model_type=ModelType(model_type), only_active=True)
# Group models by provider
provider_models: dict[str, list[ModelWithProviderEntity]] = {}
@ -505,7 +505,7 @@ class ModelProviderService:
:param model_type: model type
:return:
"""
model_type_enum = ModelType.value_of(model_type)
model_type_enum = ModelType(model_type)
try:
result = self._get_provider_manager(tenant_id).get_default_model(
@ -540,7 +540,7 @@ class ModelProviderService:
:param model: model name
:return:
"""
model_type_enum = ModelType.value_of(model_type)
model_type_enum = ModelType(model_type)
self._get_provider_manager(tenant_id).update_default_model_record(
tenant_id=tenant_id, model_type=model_type_enum, provider=provider, model=model
)
@ -590,7 +590,7 @@ class ModelProviderService:
:return:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.enable_model(model=model, model_type=ModelType.value_of(model_type))
provider_configuration.enable_model(model=model, model_type=ModelType(model_type))
def disable_model(self, tenant_id: str, provider: str, model: str, model_type: str):
"""
@ -603,4 +603,4 @@ class ModelProviderService:
:return:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.disable_model(model=model, model_type=ModelType.value_of(model_type))
provider_configuration.disable_model(model=model, model_type=ModelType(model_type))

View File

@ -207,16 +207,6 @@ describe('Select wrappers', () => {
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-popup-open:bg-state-base-hover-alt')
})
it('should include keyboard focus ring classes', async () => {
const screen = await renderOpenSelect()
await expect.element(screen.getByRole('combobox', { name: 'city select' })).toHaveClass(
'focus-visible:ring-1',
'focus-visible:ring-components-input-border-active',
'focus-visible:ring-inset',
)
})
})
describe('SelectContent', () => {

View File

@ -24,7 +24,6 @@ const selectTriggerVariants = cva(
[
'group flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
'data-placeholder:text-components-input-text-placeholder',
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',

View File

@ -49,19 +49,6 @@ describe('Switch', () => {
await expect.element(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
})
it('should work in uncontrolled mode with defaultChecked prop', async () => {
const onCheckedChange = vi.fn()
const screen = await render(<Switch defaultChecked={false} onCheckedChange={onCheckedChange} />)
const switchElement = screen.getByRole('switch')
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
asHTMLElement(switchElement.element()).click()
expect(onCheckedChange).toHaveBeenCalledWith(true)
await expect.element(switchElement).toHaveAttribute('aria-checked', 'true')
})
it('should not call onCheckedChange when disabled', async () => {
const onCheckedChange = vi.fn()
const screen = await render(<Switch checked={false} disabled onCheckedChange={onCheckedChange} />)
@ -155,24 +142,6 @@ describe('Switch', () => {
expect(screen.container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
})
it('should use checked data attributes to position spinner', async () => {
const screen = await render(<Switch checked={false} loading size="md" />)
const spinner = screen.container.querySelector('span[aria-hidden="true"]')
expect(spinner).toHaveClass(
'left-[calc(50%+6px)]',
'group-data-checked:left-[calc(50%-6px)]',
)
await screen.rerender(<Switch checked={true} loading size="md" />)
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-checked', '')
expect(screen.container.querySelector('span[aria-hidden="true"]')).toHaveClass(
'left-[calc(50%+6px)]',
'group-data-checked:left-[calc(50%-6px)]',
)
})
it('should not show spinner for xs and sm sizes', async () => {
const screen = await render(<Switch checked={false} loading size="xs" />)
expect(screen.container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()

View File

@ -2,11 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ComponentProps } from 'react'
import { useState, useTransition } from 'react'
import { Switch, SwitchSkeleton } from '.'
import {
FieldDescription,
FieldLabel,
FieldRoot,
} from '../field'
const meta = {
title: 'Base/Form/Switch',
@ -15,7 +10,7 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'Toggle switch primitive with controlled and uncontrolled state support, loading state, and skeleton placeholder.',
component: 'Toggle switch built on Base UI with CVA variants, Figma-aligned design tokens, loading spinner, and skeleton placeholder. Import `Switch` and `SwitchSkeleton` from `@langgenius/dify-ui/switch`.',
},
},
},
@ -47,27 +42,20 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
type SwitchDemoProps = Partial<Omit<ComponentProps<typeof Switch>, 'checked' | 'defaultChecked' | 'onCheckedChange'>> & {
checked?: boolean
}
const SwitchDemo = (args: SwitchDemoProps) => {
const SwitchDemo = (args: Partial<ComponentProps<typeof Switch>>) => {
const [enabled, setEnabled] = useState(args.checked ?? false)
return (
<FieldRoot name="autoRetry" className="w-72">
<FieldLabel className="flex items-center justify-between gap-3">
<span>Enable auto retry</span>
<Switch
{...args}
checked={enabled}
onCheckedChange={setEnabled}
/>
</FieldLabel>
<FieldDescription>
{enabled ? 'Failures will retry automatically.' : 'Failures require manual retry.'}
</FieldDescription>
</FieldRoot>
<div className="flex items-center justify-center gap-3">
<Switch
{...args}
checked={enabled}
onCheckedChange={setEnabled}
/>
<span className="text-sm text-gray-700">
{enabled ? 'On' : 'Off'}
</span>
</div>
)
}
@ -128,24 +116,24 @@ const AllStatesDemo = () => {
<td className="py-3 font-medium text-gray-900">{size}</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} onCheckedChange={() => {}} aria-label={`${size} unchecked switch`} />
<Switch size={size} checked={true} onCheckedChange={() => {}} aria-label={`${size} checked switch`} />
<Switch size={size} checked={false} onCheckedChange={() => {}} />
<Switch size={size} checked={true} onCheckedChange={() => {}} />
</div>
</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} disabled aria-label={`${size} disabled unchecked switch`} />
<Switch size={size} checked={true} disabled aria-label={`${size} disabled checked switch`} />
<Switch size={size} checked={false} disabled />
<Switch size={size} checked={true} disabled />
</div>
</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} loading aria-label={`${size} loading unchecked switch`} />
<Switch size={size} checked={true} loading aria-label={`${size} loading checked switch`} />
<Switch size={size} checked={false} loading />
<Switch size={size} checked={true} loading />
</div>
</td>
<td className="py-3">
<SwitchSkeleton size={size} aria-hidden="true" />
<SwitchSkeleton size={size} />
</td>
</tr>
))}
@ -160,7 +148,7 @@ export const AllStates: Story = {
parameters: {
docs: {
description: {
story: 'Variant matrix for switch sizes and states.',
story: 'Complete variant matrix: all sizes × all states, matching Figma design spec (node 2144:1210).',
},
},
},
@ -176,30 +164,22 @@ const SizeComparisonDemo = () => {
return (
<div className="flex flex-col items-center space-y-4">
<FieldRoot name="extraSmallSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
Extra Small (xs) - 14x10
</FieldLabel>
</FieldRoot>
<FieldRoot name="smallSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
Small (sm) - 20x12
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
Regular (md) - 28x16
</FieldLabel>
</FieldRoot>
<FieldRoot name="largeSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
Large (lg) - 36x20
</FieldLabel>
</FieldRoot>
<div className="flex items-center gap-3">
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
<span className="text-sm text-gray-700">Extra Small (xs) 14×10</span>
</div>
<div className="flex items-center gap-3">
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
<span className="text-sm text-gray-700">Small (sm) 20×12</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
<span className="text-sm text-gray-700">Regular (md) 28×16</span>
</div>
<div className="flex items-center gap-3">
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
<span className="text-sm text-gray-700">Large (lg) 36×20</span>
</div>
</div>
)
}
@ -220,42 +200,30 @@ const LoadingDemo = () => {
{loading ? 'Stop Loading' : 'Start Loading'}
</button>
<div className="space-y-3">
<FieldRoot name="largeUncheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={false} loading={loading} />
Large unchecked
</FieldLabel>
</FieldRoot>
<FieldRoot name="largeCheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={true} loading={loading} />
Large checked
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularUncheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={false} loading={loading} />
Regular unchecked
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularCheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={true} loading={loading} />
Regular checked
</FieldLabel>
</FieldRoot>
<FieldRoot name="smallLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="sm" checked={false} loading={loading} />
Small
</FieldLabel>
</FieldRoot>
<FieldRoot name="extraSmallLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="xs" checked={false} loading={loading} />
Extra Small
</FieldLabel>
</FieldRoot>
<div className="flex items-center gap-3">
<Switch size="lg" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Large unchecked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="lg" checked={true} loading={loading} />
<span className="text-sm text-gray-700">Large checked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Regular unchecked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={true} loading={loading} />
<span className="text-sm text-gray-700">Regular checked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="sm" checked={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" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Extra Small (no spinner)</span>
</div>
</div>
</div>
)
@ -266,7 +234,7 @@ export const Loading: Story = {
parameters: {
docs: {
description: {
story: 'Loading state disables interaction and shows a spinner for md and lg sizes.',
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.',
},
},
},
@ -274,76 +242,61 @@ export const Loading: Story = {
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
function useMockAutoRetrySettingQuery() {
const MutationLoadingDemo = () => {
const [enabled, setEnabled] = useState(false)
return {
data: {
enabled,
},
setData: setEnabled,
}
}
function useMockUpdateAutoRetrySettingMutation({
onSuccess,
}: {
onSuccess: (enabled: boolean) => void
}) {
const [requestCount, setRequestCount] = useState(0)
const [isPending, startTransition] = useTransition()
const mutate = (nextValue: boolean) => {
const handleChange = (nextValue: boolean) => {
if (isPending)
return
startTransition(async () => {
setRequestCount(current => current + 1)
await wait(1200)
onSuccess(nextValue)
setEnabled(nextValue)
})
}
return {
requestCount,
isPending,
mutate,
}
}
const MutationLoadingDemo = () => {
const autoRetrySetting = useMockAutoRetrySettingQuery()
const updateAutoRetrySetting = useMockUpdateAutoRetrySettingMutation({
onSuccess: autoRetrySetting.setData,
})
const statusText = updateAutoRetrySetting.isPending
? 'Saving changes...'
: autoRetrySetting.data.enabled
? 'Auto retry is enabled.'
: 'Auto retry is disabled.'
return (
<div className="grid w-90 gap-3 rounded-lg border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
<FieldRoot name="autoRetry">
<FieldLabel className="flex items-center justify-between gap-4">
<span className="system-sm-medium text-text-secondary">Enable auto retry</span>
<Switch
size="lg"
checked={autoRetrySetting.data.enabled}
loading={updateAutoRetrySetting.isPending}
onCheckedChange={updateAutoRetrySetting.mutate}
/>
</FieldLabel>
<FieldDescription>Retry failed workflow runs without manual intervention.</FieldDescription>
</FieldRoot>
<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-sm bg-state-base-hover px-1 py-0.5 text-[11px]">loading</code>
{' '}
and rejects duplicate clicks.
</p>
</div>
<span className="text-xs text-text-tertiary" aria-live="polite">
{statusText}
{' '}
Save attempts:
{' '}
{updateAutoRetrySetting.requestCount}
</span>
<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="lg"
checked={enabled}
loading={isPending}
onCheckedChange={handleChange}
aria-label="Enable Auto Retry"
/>
</div>
<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="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>
)
}
@ -353,7 +306,7 @@ export const MutationLoadingGuard: Story = {
parameters: {
docs: {
description: {
story: 'Controlled switch that enters loading while the change is saved.',
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.',
},
},
},
@ -362,19 +315,19 @@ export const MutationLoadingGuard: Story = {
const SkeletonDemo = () => (
<div className="flex flex-col items-center space-y-4">
<div className="flex items-center gap-3">
<SwitchSkeleton size="xs" aria-hidden="true" />
<SwitchSkeleton size="xs" />
<span className="text-sm text-gray-700">Extra Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="sm" aria-hidden="true" />
<SwitchSkeleton size="sm" />
<span className="text-sm text-gray-700">Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="md" aria-hidden="true" />
<SwitchSkeleton size="md" />
<span className="text-sm text-gray-700">Regular skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="lg" aria-hidden="true" />
<SwitchSkeleton size="lg" />
<span className="text-sm text-gray-700">Large skeleton</span>
</div>
</div>
@ -385,7 +338,7 @@ export const Skeleton: Story = {
parameters: {
docs: {
description: {
story: 'Non-interactive placeholders for switch loading layouts.',
story: '`SwitchSkeleton` renders a non-interactive placeholder with `bg-text-quaternary opacity-20`. Exported from `@langgenius/dify-ui/switch` alongside `Switch`.',
},
},
},

View File

@ -45,34 +45,26 @@ const switchThumbVariants = cva(
export type SwitchSize = NonNullable<VariantProps<typeof switchRootVariants>['size']>
const switchSpinnerVariants = cva(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
{
variants: {
size: {
md: 'size-2 left-[calc(50%+6px)] group-data-checked:left-[calc(50%-6px)]',
lg: 'size-2.5 left-[calc(50%+8px)] group-data-checked:left-[calc(50%-8px)]',
},
},
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 ControlledSwitchProps = {
checked: boolean
defaultChecked?: never
}
type UncontrolledSwitchProps = {
checked?: never
defaultChecked?: boolean
}
type SwitchControlProps = ControlledSwitchProps | UncontrolledSwitchProps
export type SwitchProps
= Omit<BaseSwitchNS.Root.Props, 'checked' | 'defaultChecked' | 'className' | 'size' | 'onCheckedChange'>
= Omit<BaseSwitchNS.Root.Props, 'className' | 'size' | 'onCheckedChange'>
& VariantProps<typeof switchRootVariants>
& SwitchControlProps
& {
onCheckedChange?: (checked: boolean) => void
loading?: boolean
@ -89,6 +81,7 @@ export function Switch({
...props
}: SwitchProps) {
const isDisabled = disabled || loading
const spinner = loading && size ? spinnerSizeConfig[size] : undefined
return (
<BaseSwitch.Root
@ -102,10 +95,14 @@ export function Switch({
<BaseSwitch.Thumb
className={switchThumbVariants({ size })}
/>
{loading && (size === 'md' || size === 'lg')
{spinner
? (
<span
className={switchSpinnerVariants({ size })}
className={cn(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
spinner.icon,
checked ? 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" />
@ -134,8 +131,11 @@ const switchSkeletonVariants = cva(
)
export type SwitchSkeletonProps
= HTMLAttributes<HTMLDivElement>
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
& VariantProps<typeof switchSkeletonVariants>
& {
className?: string
}
export function SwitchSkeleton({
size = 'md',

View File

@ -41,7 +41,6 @@ describe('@langgenius/dify-ui/toast', () => {
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite')
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-60')
expect(screen.getByRole('region', { name: 'Notifications' }).element().firstElementChild).toHaveClass('top-4')
expect(screen.getByText('Saved').element().closest('[class*="transition-opacity"]')).toHaveClass('motion-reduce:transition-none')
expect(screen.getByRole('dialog').element()).not.toHaveClass('outline-hidden')
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).toBeInTheDocument()

View File

@ -171,7 +171,7 @@ function ToastCard({
aria-hidden="true"
className={cn('absolute -inset-px bg-linear-to-r opacity-40', getToneGradientClasses(toastType))}
/>
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100 motion-reduce:transition-none">
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100">
<div className="flex shrink-0 items-center justify-center p-0.5">
<ToastIcon type={toastType} />
</div>