refactor(ui): consolidate toggle switches

This commit is contained in:
Brennan Benson
2026-08-09 23:30:05 -07:00
parent 73efab98f7
commit 198c0ba311
31 changed files with 269 additions and 707 deletions
@@ -14,6 +14,7 @@ import {
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { SwitchIndicator } from '@/components/ui/switch'
import { SettingsSwitch } from '@/components/settings/SettingsFormControls'
import type RepoCombobox from '@/components/repo/RepoCombobox'
import AgentCombobox from '@/components/agent/AgentCombobox'
@@ -1209,20 +1210,7 @@ export default function NewWorkspaceComposerCard({
onClick={() => onCreateMultipleChange?.(!createMultiple)}
className="group flex w-fit cursor-pointer items-center gap-2 rounded-md text-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<span
aria-hidden
className={cn(
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors',
createMultiple ? 'bg-foreground' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
createMultiple ? 'translate-x-4.5' : 'translate-x-0.5'
)}
/>
</span>
<SwitchIndicator checked={createMultiple} />
<span className="text-muted-foreground transition-colors group-hover:text-foreground">
{translate('auto.components.NewWorkspaceComposerCard.createMultiple', 'Create more')}
</span>
@@ -1,5 +1,5 @@
import type { JSX } from 'react'
import { cn } from '@/lib/utils'
import { Switch } from '@/components/ui/switch'
import { useAppStore } from '@/store'
import type { ContextualTourStepControl } from '../../../../shared/contextual-tours'
import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from './contextual-tour-composer-events'
@@ -43,33 +43,20 @@ function AutoRenameBranchFromWorkControl(): JSX.Element {
)}
</div>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
<Switch
checked={enabled}
aria-label={translate(
'auto.components.contextual.tours.ContextualTourControl.186eecc34f',
'Auto-name workspace from first agent message'
)}
onClick={() => {
onCheckedChange={() => {
toggleAutoRenameBranchFromWork({
enabled,
updateSettings,
dispatchEvent: (event) => window.dispatchEvent(event)
})
}}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors',
enabled ? 'bg-foreground' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
enabled ? 'translate-x-4.5' : 'translate-x-0.5'
)}
/>
</button>
/>
</div>
</div>
)
@@ -1,5 +1,5 @@
import type { JSX } from 'react'
import { cn } from '@/lib/utils'
import { Switch } from '@/components/ui/switch'
type AiCommitPrSettingsSwitchProps = {
checked: boolean
@@ -12,24 +12,5 @@ export function AiCommitPrSettingsSwitch({
label,
onToggle
}: AiCommitPrSettingsSwitchProps): JSX.Element {
return (
<button
type="button"
role="switch"
aria-label={label}
aria-checked={checked}
onClick={onToggle}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors',
checked ? 'bg-foreground' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
checked ? 'translate-x-4.5' : 'translate-x-0.5'
)}
/>
</button>
)
return <Switch aria-label={label} checked={checked} onCheckedChange={onToggle} />
}
@@ -1,5 +1,5 @@
import type { JSX } from 'react'
import { cn } from '@/lib/utils'
import { Switch } from '@/components/ui/switch'
import { getAgentAwakeDescription, getAgentAwakeTitle } from '../settings/agent-awake-copy'
import type { GlobalSettings } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
@@ -33,23 +33,13 @@ export function KeepAwakeCard(props: {
{getAgentAwakeDescription()}
</p>
</div>
<button
role="switch"
<Switch
aria-label={title}
aria-checked={enabled}
onClick={() => updateSettings(computerAwakeSettingsForMode(enabled ? 'off' : 'auto'))}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors',
enabled ? 'bg-foreground' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
enabled ? 'translate-x-4.5' : 'translate-x-0.5'
)}
/>
</button>
checked={enabled}
onCheckedChange={(checked) =>
updateSettings(computerAwakeSettingsForMode(checked ? 'auto' : 'off'))
}
/>
</div>
</div>
)
@@ -19,6 +19,7 @@ import { Label } from '../ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { Separator } from '../ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { Switch } from '../ui/switch'
import {
AlertTriangle,
ExternalLink,
@@ -1555,25 +1556,15 @@ export function AccountsPane({
)}
</p>
</div>
<button
role="switch"
aria-checked={settings.geminiCliOAuthEnabled}
onClick={() => {
<Switch
checked={settings.geminiCliOAuthEnabled}
onCheckedChange={(checked) => {
recordFeatureInteraction('usage-tracking')
updateSettings({
geminiCliOAuthEnabled: !settings.geminiCliOAuthEnabled
geminiCliOAuthEnabled: checked
})
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.geminiCliOAuthEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.geminiCliOAuthEnabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
/>
</SearchableSetting>
</section>
) : null,
@@ -18,6 +18,7 @@ import { SourceControlActionVariableChips } from '../source-control/SourceContro
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'
import { Label } from '../ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { Switch } from '../ui/switch'
import { getAutoRenameBranchAdvancedSearchEntries } from './auto-rename-branch-search'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
@@ -172,24 +173,14 @@ export function AutoRenameBranchFromWorkSetting({
)}
</p>
</div>
<button
role="switch"
aria-checked={settings.autoRenameBranchFromWork}
onClick={() =>
<Switch
checked={settings.autoRenameBranchFromWork}
onCheckedChange={(checked) =>
updateSettings({
autoRenameBranchFromWork: !settings.autoRenameBranchFromWork
autoRenameBranchFromWork: checked
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.autoRenameBranchFromWork ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.autoRenameBranchFromWork ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
/>
</div>
<Collapsible open={advancedOpen} onOpenChange={setOptionsOpen}>
@@ -1,5 +1,6 @@
import type { GlobalSettings } from '../../../../shared/types'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
import { SearchableSetting } from './SearchableSetting'
import { translate } from '@/i18n/i18n'
@@ -39,25 +40,15 @@ export function BrowserLinkRoutingSetting({
</Label>
<p className="text-xs text-muted-foreground">{linkRoutingDescription}</p>
</div>
<button
role="switch"
aria-checked={settings.openLinksInApp}
onClick={() =>
<Switch
checked={settings.openLinksInApp}
onCheckedChange={(checked) =>
updateSettings({
openLinksInApp: !settings.openLinksInApp,
openLinksInApp: checked,
openLinksInAppPreferencePrompted: true
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.openLinksInApp ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.openLinksInApp ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
/>
</SearchableSetting>
)
}
@@ -1,4 +1,5 @@
import { translate } from '@/i18n/i18n'
import { Switch } from '@/components/ui/switch'
export function BrowserUseEnableSwitch({
enabled,
onToggle
@@ -7,23 +8,13 @@ export function BrowserUseEnableSwitch({
onToggle: () => void
}): React.JSX.Element {
return (
<button
role="switch"
aria-checked={enabled}
<Switch
checked={enabled}
aria-label={translate(
'auto.components.settings.BrowserUseEnableSwitch.aea3f45349',
'Enable Agent Browser Use'
)}
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
enabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
enabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
onCheckedChange={onToggle}
/>
)
}
@@ -20,6 +20,7 @@ import {
import { useMountedRef } from '@/hooks/useMountedRef'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
import { CliRegistrationDialog } from './CliRegistrationDialog'
@@ -288,21 +289,12 @@ export function CliSection({
</Tooltip>
</TooltipProvider>
{!isBrowserManaged ? (
<button
role="switch"
aria-checked={isEnabled}
<Switch
checked={isEnabled}
disabled={loading || !isSupported || pathStatusUnknown || busyAction !== null}
onClick={() => setDialogOpen(true)}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
isEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
} ${loading || !isSupported || pathStatusUnknown || busyAction !== null ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
isEnabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
onCheckedChange={() => setDialogOpen(true)}
className="disabled:opacity-60"
/>
) : null}
</div>
</div>
@@ -21,6 +21,7 @@ import { getRuntimeGitScope } from '../../runtime/runtime-git-client'
import { useAppStore } from '../../store'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
import { SearchableSetting } from './SearchableSetting'
import { SourceControlAiActionRecipeDefaults } from './SourceControlAiActionRecipeDefaults'
import { matchesSettingsSearch } from './settings-search'
@@ -199,20 +200,7 @@ export function CommitMessageAiPane({
)}
</p>
</div>
<button
role="switch"
aria-checked={config.enabled}
onClick={onToggleEnabled}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
config.enabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
config.enabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
<Switch checked={config.enabled} onCheckedChange={onToggleEnabled} />
</SearchableSetting>
)
}
@@ -1,5 +1,6 @@
import type { GlobalSettings } from '../../../../shared/types'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
import { useAppStore } from '../../store'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
@@ -86,23 +87,12 @@ export function ExperimentalPane({
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.experimentalPet}
onClick={() => {
updateSettings({ experimentalPet: !settings.experimentalPet })
<Switch
checked={settings.experimentalPet}
onCheckedChange={(checked) => {
updateSettings({ experimentalPet: checked })
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.experimentalPet ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.experimentalPet ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
/>
</div>
</SearchableSetting>
) : null}
@@ -129,25 +119,14 @@ export function ExperimentalPane({
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.experimentalActivity}
onClick={() =>
<Switch
checked={settings.experimentalActivity}
onCheckedChange={(checked) =>
updateSettings({
experimentalActivity: !settings.experimentalActivity
experimentalActivity: checked
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.experimentalActivity ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.experimentalActivity ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
/>
</div>
</SearchableSetting>
) : null}
@@ -188,25 +167,14 @@ export function ExperimentalPane({
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.experimentalTerminalAttention}
onClick={() =>
<Switch
checked={settings.experimentalTerminalAttention}
onCheckedChange={(checked) =>
updateSettings({
experimentalTerminalAttention: !settings.experimentalTerminalAttention
experimentalTerminalAttention: checked
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.experimentalTerminalAttention ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.experimentalTerminalAttention ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
/>
</div>
</SearchableSetting>
) : null}
@@ -4,6 +4,7 @@ import type { SourceControlAiSettingsPatch } from '../../../../shared/source-con
import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from '../../../../shared/source-control-group-order'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
import { useAppStore } from '../../store'
import { getGitPaneSearchEntries } from './git-search'
import { SearchableSetting } from './SearchableSetting'
@@ -275,26 +276,14 @@ export function GitPane({
)}
</p>
</div>
<button
role="switch"
aria-checked={settings.refreshLocalBaseRefOnWorktreeCreate}
onClick={() =>
<Switch
checked={settings.refreshLocalBaseRefOnWorktreeCreate}
onCheckedChange={(checked) =>
updateSettings({
refreshLocalBaseRefOnWorktreeCreate: !settings.refreshLocalBaseRefOnWorktreeCreate
refreshLocalBaseRefOnWorktreeCreate: checked
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.refreshLocalBaseRefOnWorktreeCreate
? 'bg-foreground'
: 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.refreshLocalBaseRefOnWorktreeCreate ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
/>
</SearchableSetting>
) : null,
matchesSettingsSearch(searchQuery, {
@@ -371,24 +360,14 @@ export function GitPane({
)}
</p>
</div>
<button
role="switch"
aria-checked={settings.enableGitHubAttribution}
onClick={() =>
<Switch
checked={settings.enableGitHubAttribution}
onCheckedChange={(checked) =>
updateSettings({
enableGitHubAttribution: !settings.enableGitHubAttribution
enableGitHubAttribution: checked
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.enableGitHubAttribution ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.enableGitHubAttribution ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
/>
</SearchableSetting>
) : null
].filter(Boolean)
@@ -1,4 +1,5 @@
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
import { translate } from '@/i18n/i18n'
// Why: anything in this group is deliberately unfinished or staff-only. The
@@ -38,17 +39,16 @@ export function HiddenExperimentalGroup(): React.JSX.Element {
)}
</p>
</div>
<button
type="button"
<Switch
aria-label={translate(
'auto.components.settings.HiddenExperimentalGroup.d0f914a528',
'Placeholder toggle'
)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-not-allowed items-center rounded-full border border-orange-500/40 bg-orange-500/20 opacity-70"
checked={false}
className="border-orange-500/40 data-[state=unchecked]:bg-orange-500/20 disabled:opacity-70"
disabled
>
<span className="inline-block h-3.5 w-3.5 translate-x-0.5 transform rounded-full bg-orange-200 shadow-sm dark:bg-orange-100" />
</button>
thumbClassName="data-[state=unchecked]:bg-orange-200 dark:data-[state=unchecked]:bg-orange-100"
/>
</div>
</section>
)
@@ -1,5 +1,6 @@
import type { GlobalSettings } from '../../../../shared/types'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
import { SearchableSetting } from './SearchableSetting'
import { isDefaultPrimarySelectionMiddleClickPasteUserAgent } from '@/hooks/usePrimarySelectionPaste'
import { translate } from '@/i18n/i18n'
@@ -55,25 +56,14 @@ export function InputPane({ settings, updateSettings }: InputPaneProps): React.J
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={() =>
<Switch
checked={enabled}
onCheckedChange={(checked) =>
updateSettings({
primarySelectionMiddleClickPaste: !enabled
primarySelectionMiddleClickPaste: checked
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
enabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
enabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
/>
</SearchableSetting>
</section>
)
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
export type NotificationSettingToggleProps = {
label: string
@@ -27,22 +28,7 @@ export function NotificationSettingToggle({
</div>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<button
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
checked ? 'bg-foreground' : 'bg-muted-foreground/30'
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
checked ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
<Switch checked={checked} aria-label={label} disabled={disabled} onCheckedChange={onToggle} />
</div>
)
}
@@ -166,6 +166,9 @@ describe('PrivacyPane — computeBlockedReason', () => {
})
describe('PrivacyPane — markup respects blocked state', () => {
const switchTag = (markup: string): string =>
markup.match(/<button[^>]*role="switch"[^>]*>/)?.[0] ?? ''
// The useEffect that fetches consent does not run in renderToStaticMarkup
// (no DOM, no state flushes), so the rendered toggle always reads the
// `consent === null` branch of the effect. Env-var blocked-state markup
@@ -189,9 +192,7 @@ describe('PrivacyPane — markup respects blocked state', () => {
})
})
)
// Negative assertion: the disabled attribute should not appear on the
// switch. renderToStaticMarkup elides `disabled={false}` entirely.
expect(markup).not.toMatch(/role="switch"[^>]*disabled/)
expect(switchTag(markup)).not.toContain(' disabled=""')
})
it('leaves the toggle enabled for an existing user awaiting the banner (flip dismisses the banner)', () => {
@@ -206,7 +207,7 @@ describe('PrivacyPane — markup respects blocked state', () => {
})
})
)
expect(markup).not.toMatch(/role="switch"[^>]*disabled/)
expect(switchTag(markup)).not.toContain(' disabled=""')
})
it('leaves the toggle enabled once the existing-user banner is resolved', () => {
@@ -218,6 +219,6 @@ describe('PrivacyPane — markup respects blocked state', () => {
})
})
)
expect(markup).not.toMatch(/role="switch"[^>]*disabled/)
expect(switchTag(markup)).not.toContain(' disabled=""')
})
})
@@ -4,6 +4,7 @@ import { useMountedRef } from '@/hooks/useMountedRef'
import type { GlobalSettings } from '../../../../shared/types'
import type { TelemetryConsentState } from '../../../../shared/telemetry-consent-types'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
import { PRIVACY_URL, getConsentState, setOptIn as telemetrySetOptIn } from '../../lib/telemetry'
import { useAppStore } from '../../store'
import { PrivacyDiagnosticsSection } from './PrivacyDiagnosticsSection'
@@ -111,26 +112,16 @@ export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element {
.
</p>
</div>
<button
role="switch"
aria-checked={toggleChecked}
<Switch
checked={toggleChecked}
aria-label={translate(
'auto.components.settings.PrivacyPane.fe904ac984',
'Share anonymous usage data'
)}
aria-describedby={blocked ? PRIVACY_PANE_BLOCKED_HELPER_ID : undefined}
disabled={blocked !== null || inFlight}
onClick={handleToggle}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
toggleChecked ? 'bg-foreground' : 'bg-muted-foreground/30'
} ${blocked !== null || inFlight ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
toggleChecked ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
onCheckedChange={() => void handleToggle()}
/>
</div>
{blocked ? <BlockedHelper blocked={blocked} id={PRIVACY_PANE_BLOCKED_HELPER_ID} /> : null}
@@ -8,6 +8,7 @@ import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { Switch } from '../ui/switch'
import { Check, ChevronsUpDown, CircleX } from 'lucide-react'
import { normalizeColor, type TerminalThemeOption } from '@/lib/terminal-theme'
import { MAX_THEME_RESULTS } from './SettingsConstants'
@@ -36,26 +37,13 @@ export function SettingsSwitch({
disabled
}: SettingsSwitchProps): React.JSX.Element {
return (
<button
type="button"
role="switch"
aria-checked={checked}
<Switch
checked={checked}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
disabled={disabled}
onClick={onChange}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-foreground' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
checked ? 'translate-x-4.5' : 'translate-x-0.5'
)}
/>
</button>
onCheckedChange={onChange}
/>
)
}
@@ -3,6 +3,7 @@ import { RotateCw } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
import { ColorField, NumberField } from './SettingsFormControls'
import { SearchableSetting } from './SearchableSetting'
import { clampNumber } from '@/lib/terminal-theme'
@@ -119,24 +120,10 @@ export function TerminalWindowSection({
)}
</p>
</div>
<button
role="switch"
aria-checked={settings.windowBackgroundBlur ?? false}
onClick={() =>
updateSettings({ windowBackgroundBlur: !settings.windowBackgroundBlur })
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
(settings.windowBackgroundBlur ?? false)
? 'bg-foreground'
: 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
(settings.windowBackgroundBlur ?? false) ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
<Switch
checked={settings.windowBackgroundBlur ?? false}
onCheckedChange={(checked) => updateSettings({ windowBackgroundBlur: checked })}
/>
</div>
{blurPendingRestart ? (
@@ -253,28 +240,14 @@ export function TerminalWindowSection({
)}
</Label>
</div>
<button
role="switch"
aria-checked={settings.terminalMouseHideWhileTyping ?? false}
onClick={() =>
<Switch
checked={settings.terminalMouseHideWhileTyping ?? false}
onCheckedChange={(checked) =>
updateSettings({
terminalMouseHideWhileTyping: !settings.terminalMouseHideWhileTyping
terminalMouseHideWhileTyping: checked
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
(settings.terminalMouseHideWhileTyping ?? false)
? 'bg-foreground'
: 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
(settings.terminalMouseHideWhileTyping ?? false)
? 'translate-x-4.5'
: 'translate-x-0.5'
}`}
/>
</button>
/>
</SearchableSetting>
<SearchableSetting
@@ -1,6 +1,7 @@
import type { VoiceSettings } from '../../../../shared/speech-types'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { Switch } from '../ui/switch'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import { VoiceMicrophoneSetting } from './VoiceMicrophoneSetting'
@@ -35,26 +36,17 @@ export function VoiceDictationSettingsSection({
)}
</p>
</div>
<button
role="switch"
aria-checked={voiceSettings.enabled}
<Switch
checked={voiceSettings.enabled}
aria-label={translate(
'auto.components.settings.VoicePane.0121960365',
'Enable Voice Dictation'
)}
aria-busy={permissionPending}
disabled={permissionPending}
onClick={() => void onToggleVoiceDictation()}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
voiceSettings.enabled ? 'bg-foreground' : 'bg-muted-foreground/30'
} ${permissionPending ? 'cursor-wait opacity-70' : ''}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
voiceSettings.enabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
onCheckedChange={() => void onToggleVoiceDictation()}
className="disabled:cursor-wait disabled:opacity-70"
/>
</div>
<Separator />
@@ -14,6 +14,7 @@ import {
DialogTitle
} from '../ui/dialog'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { translate } from '@/i18n/i18n'
@@ -187,21 +188,12 @@ export function WslCliRegistration({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
role="switch"
aria-checked={isEnabled}
<Switch
checked={isEnabled}
disabled={loading || !isSupported || busyAction !== null}
onClick={() => setDialogOpen(true)}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
isEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
} ${loading || !isSupported || busyAction !== null ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
isEnabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
onCheckedChange={() => setDialogOpen(true)}
className="disabled:opacity-60"
/>
</div>
</div>
@@ -1,5 +1,6 @@
import React from 'react'
import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
import { SwitchIndicator } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
/**
@@ -51,20 +52,11 @@ export function FilterToggleRow({
</span>
<span className="inline-flex items-center gap-2">
{shortcutLabel ? <DropdownMenuShortcut>{shortcutLabel}</DropdownMenuShortcut> : null}
<span
aria-hidden
className={cn(
'relative h-3.5 w-6 shrink-0 rounded-full transition-colors',
checked ? 'bg-primary' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'absolute top-0.5 left-0.5 size-2.5 rounded-full bg-background shadow-sm transition-transform',
checked && 'translate-x-2.5'
)}
/>
</span>
<SwitchIndicator
checked={checked}
size="compact"
className="data-[state=checked]:bg-primary"
/>
</span>
</button>
)
@@ -11,6 +11,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { SwitchIndicator } from '@/components/ui/switch'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
import { getAllWorktreesFromState } from '@/store/selectors'
@@ -241,20 +242,7 @@ export function HostRemoveDialog({
onClick={() => setDeleteWorkspaces((v) => !v)}
className="group mt-0.5 flex shrink-0 cursor-pointer items-center rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<span
aria-hidden
className={cn(
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors',
deleteWorkspaces ? 'bg-foreground' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
deleteWorkspaces ? 'translate-x-4.5' : 'translate-x-0.5'
)}
/>
</span>
<SwitchIndicator checked={deleteWorkspaces} />
</button>
<span className="min-w-0 flex-1 text-xs leading-snug">
<span className="font-medium text-foreground">{deleteOptionLabel}</span>
@@ -1,4 +1,5 @@
import { RefreshCw } from 'lucide-react'
import { SwitchIndicator } from '../ui/switch'
type ClaudeUsageLoadingStateProps = {
title?: string
@@ -20,9 +21,11 @@ export function ClaudeUsageLoadingState({
</div>
<div className="flex shrink-0 items-center gap-2 self-start">
<RefreshCw className="size-3.5 animate-spin text-muted-foreground" />
<div className="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent bg-foreground/80">
<span className="pointer-events-none block size-3.5 translate-x-4.5 rounded-full bg-background shadow-sm" />
</div>
<SwitchIndicator
checked
className="data-[state=checked]:bg-foreground/80"
thumbClassName="transition-none"
/>
</div>
</div>
@@ -13,6 +13,7 @@ import {
import type { ClaudeUsageRange, ClaudeUsageScope } from '../../../../shared/claude-usage-types'
import { useAppStore } from '../../store'
import { Button } from '../ui/button'
import { Switch } from '../ui/switch'
import {
DropdownMenu,
DropdownMenuContent,
@@ -103,19 +104,14 @@ export function ClaudeUsagePane(): React.JSX.Element {
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={false}
<Switch
checked={false}
aria-label={translate(
'auto.components.stats.ClaudeUsagePane.424cd50412',
'Enable Claude usage analytics'
)}
onClick={() => handleSetEnabled(true)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform" />
</button>
onCheckedChange={handleSetEnabled}
/>
</div>
</div>
)
@@ -222,19 +218,14 @@ export function ClaudeUsagePane(): React.JSX.Element {
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
type="button"
role="switch"
aria-checked={true}
<Switch
checked
aria-label={translate(
'auto.components.stats.ClaudeUsagePane.424cd50412',
'Enable Claude usage analytics'
)}
onClick={() => handleSetEnabled(false)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-4.5 rounded-full bg-background shadow-sm transition-transform" />
</button>
onCheckedChange={handleSetEnabled}
/>
</div>
</div>
@@ -12,6 +12,7 @@ import {
import type { CodexUsageRange, CodexUsageScope } from '../../../../shared/codex-usage-types'
import { useAppStore } from '../../store'
import { Button } from '../ui/button'
import { Switch } from '../ui/switch'
import {
DropdownMenu,
DropdownMenuContent,
@@ -99,19 +100,14 @@ export function CodexUsagePane(): React.JSX.Element {
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={false}
<Switch
checked={false}
aria-label={translate(
'auto.components.stats.CodexUsagePane.f7c1affbd5',
'Enable Codex usage analytics'
)}
onClick={() => handleSetEnabled(true)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform" />
</button>
onCheckedChange={handleSetEnabled}
/>
</div>
</div>
)
@@ -224,19 +220,14 @@ export function CodexUsagePane(): React.JSX.Element {
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
type="button"
role="switch"
aria-checked={true}
<Switch
checked
aria-label={translate(
'auto.components.stats.CodexUsagePane.f7c1affbd5',
'Enable Codex usage analytics'
)}
onClick={() => handleSetEnabled(false)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-4.5 rounded-full bg-background shadow-sm transition-transform" />
</button>
onCheckedChange={handleSetEnabled}
/>
</div>
</div>
@@ -15,6 +15,7 @@ import type {
} from '../../../../shared/opencode-usage-types'
import { useAppStore } from '../../store'
import { Button } from '../ui/button'
import { Switch } from '../ui/switch'
import {
DropdownMenu,
DropdownMenuContent,
@@ -107,19 +108,14 @@ export function OpenCodeUsagePane(): React.JSX.Element {
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={false}
<Switch
checked={false}
aria-label={translate(
'auto.components.stats.OpenCodeUsagePane.f04131b3be',
'Enable OpenCode usage analytics'
)}
onClick={() => handleSetEnabled(true)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform" />
</button>
onCheckedChange={handleSetEnabled}
/>
</div>
</div>
)
@@ -235,19 +231,14 @@ export function OpenCodeUsagePane(): React.JSX.Element {
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
type="button"
role="switch"
aria-checked={true}
<Switch
checked
aria-label={translate(
'auto.components.stats.OpenCodeUsagePane.f04131b3be',
'Enable OpenCode usage analytics'
)}
onClick={() => handleSetEnabled(false)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-4.5 rounded-full bg-background shadow-sm transition-transform" />
</button>
onCheckedChange={handleSetEnabled}
/>
</div>
</div>
@@ -1,4 +1,5 @@
import { translate } from '@/i18n/i18n'
import { Switch } from '@/components/ui/switch'
type TerminalQuickCommandAppendEnterSwitchProps = {
appendEnter: boolean
onToggle: () => void
@@ -24,25 +25,14 @@ export function TerminalQuickCommandAppendEnterSwitch({
)}
</div>
</div>
<button
type="button"
role="switch"
aria-checked={appendEnter}
<Switch
checked={appendEnter}
aria-label={translate(
'auto.components.terminal.quick.commands.TerminalQuickCommandAppendEnterSwitch.e4e5fed3b3',
'Toggle append Enter'
)}
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
appendEnter ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
appendEnter ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
onCheckedChange={onToggle}
/>
</div>
)
}
@@ -1,231 +0,0 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { relative, resolve } from 'node:path'
// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API.
import ts from 'typescript-api'
import { describe, expect, it } from 'vitest'
// Why: the h-5 w-9 switch is hand-rolled at every call site instead of living in a
// primitive, so the handle offset drifts. The 1px-bordered track leaves a 34px content
// box for the 14px (`size-3.5`) handle, and `translate-x-0.5` insets the off state by
// 2px, so a symmetric on state is 34 - 14 - 2 = 18px (`translate-x-4.5`).
const RENDERER_ROOT = resolve('src/renderer/src')
const OFF_STATE = 'translate-x-0.5'
const ON_STATE = 'translate-x-4.5'
/** The track geometry the arithmetic above is derived from; other switch sizes are not ours to check. */
const TRACK = ['h-5', 'w-9']
/** The handle's 14px size, in both the shorthand and long-hand spellings in use. */
const HANDLE_SIZE = [['size-3.5'], ['h-3.5', 'w-3.5']]
/** Every current handle is the round knob; this is what separates it from a size-3.5 icon in the same track. */
const HANDLE_SHAPE = 'rounded-full'
/** Any horizontal translate — negative, arbitrary, variant-prefixed or named — so no respelling reads as "no offset". */
const OFFSET = /(?:^|:)-?translate-x-\S+$/
/** Anything that moves the track off the 34px content box the 18px on-state is derived from. */
const BORDER_WIDTH = /^border(?:-[xytrbles])?-(?:\d+|\[)/
const TRACK_PADDING = /^p[xytrbles]?-(?:\d|\[)/
function collectSourceFiles(dir: string, files: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const filePath = resolve(dir, name)
if (statSync(filePath).isDirectory()) {
collectSourceFiles(filePath, files)
} else if (name.endsWith('.tsx') && !name.endsWith('.test.tsx')) {
files.push(filePath)
}
}
return files
}
/** Class tokens anywhere under a node, flattening the ternaries, template holes and `cn()` args a className is built from. */
function tokensIn(node: ts.Node): string[] {
const tokens: string[] = []
const read = (child: ts.Node): void => {
if (ts.isStringLiteral(child) || ts.isTemplateLiteralToken(child)) {
tokens.push(...child.text.split(/\s+/).filter(Boolean))
}
ts.forEachChild(child, read)
}
read(node)
return tokens
}
/** Reading the className attribute's own subtree keeps a child element's classes out of its parent's token set. */
function classNameOf(element: ts.JsxOpeningLikeElement): ts.Node | undefined {
const className = element.attributes.properties.find(
(property): property is ts.JsxAttribute =>
ts.isJsxAttribute(property) && property.name.getText() === 'className'
)
return className?.initializer
}
function hasAll(tokens: string[], required: string[]): boolean {
return required.every((token) => tokens.includes(token))
}
/**
* The offsets each side of a conditional className contributes. A handle that toggles is
* two-state by construction, so this tells a deliberately single-state knob apart from one
* that lost a branch — which a flat token list cannot.
*/
function offsetBranches(node: ts.Node): [string[], string[]][] {
const branches: [string[], string[]][] = []
const read = (child: ts.Node): void => {
if (ts.isConditionalExpression(child)) {
const whenTrue = tokensIn(child.whenTrue).filter((token) => OFFSET.test(token))
const whenFalse = tokensIn(child.whenFalse).filter((token) => OFFSET.test(token))
if (whenTrue.length > 0 || whenFalse.length > 0) {
branches.push([whenTrue, whenFalse])
return
}
}
ts.forEachChild(child, read)
}
read(node)
return branches
}
function trackDrift(tokens: string[]): string | undefined {
if (!tokens.includes('border')) {
return 'no 1px border'
}
if (tokens.includes('box-content')) {
return 'box-content'
}
return tokens.find((token) => BORDER_WIDTH.test(token) || TRACK_PADDING.test(token))
}
type Handle = { at: string; offsets: string[] }
type Toggle = { at: string; branches: [string[], string[]] }
type Scan = { handles: Handle[]; toggles: Toggle[]; drifted: string[]; knobless: string[] }
/**
* Handles are found *inside* a track element rather than by scanning source text forward, so
* neither a sibling element nor a same-file icon can be mistaken for the knob.
*/
function collectFile(filePath: string, source: string, scan: Scan): void {
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
)
const at = (node: ts.Node): string => {
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
return `${relative(RENDERER_ROOT, filePath)}:${line + 1}`
}
const readHandles = (node: ts.Node): void => {
if (ts.isJsxOpeningLikeElement(node)) {
const className = classNameOf(node)
const tokens = className ? tokensIn(className) : []
if (
className &&
HANDLE_SIZE.some((size) => hasAll(tokens, size)) &&
tokens.includes(HANDLE_SHAPE)
) {
scan.handles.push({ at: at(node), offsets: tokens.filter((token) => OFFSET.test(token)) })
for (const branches of offsetBranches(className)) {
scan.toggles.push({ at: at(node), branches })
}
}
}
ts.forEachChild(node, readHandles)
}
const visit = (node: ts.Node): void => {
if (ts.isJsxOpeningLikeElement(node)) {
const className = classNameOf(node)
const tokens = className ? tokensIn(className) : []
if (hasAll(tokens, TRACK)) {
const drift = trackDrift(tokens)
if (drift) {
scan.drifted.push(`${at(node)}: ${drift}`)
}
// The knob lives in the track's subtree; a self-closing track has no subtree to walk.
const found = scan.handles.length
if (ts.isJsxOpeningElement(node)) {
readHandles(node.parent)
}
// A track whose knob moved into a child component would otherwise leave the scan silently.
if (scan.handles.length === found) {
scan.knobless.push(at(node))
}
return
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
}
let cached: Scan | undefined
/** Parsing every gated file is the expensive part, so all three assertions share one scan. */
function scanRenderer(): Scan {
if (!cached) {
const scan: Scan = { handles: [], toggles: [], drifted: [], knobless: [] }
for (const filePath of collectSourceFiles(RENDERER_ROOT)) {
const source = readFileSync(filePath, 'utf8')
if (TRACK.every((token) => source.includes(token))) {
collectFile(filePath, source, scan)
}
}
// A track nested inside another h-5 w-9 element would otherwise be collected twice.
const seen = new Set<string>()
scan.handles = scan.handles.filter((handle) => !seen.has(handle.at) && seen.add(handle.at))
cached = scan
}
// A scan that stops finding switches has lost its bounds, not proven the tree clean. The
// floor only has to catch a collapse — `knobless` and `drifted` catch the structural cases —
// so it sits well under the ~34 handles in the tree rather than pinning the current count.
expect(cached.handles.length, 'the switch scan lost its bounds').toBeGreaterThan(25)
return cached
}
describe('toggle switch handle alignment', () => {
it('insets the handle by 2px at both ends of the track', () => {
const offenders = scanRenderer().handles.flatMap((handle) =>
handle.offsets.length === 0
? [`${handle.at}: no translate-x offset`]
: handle.offsets
.filter((offset) => offset !== OFF_STATE && offset !== ON_STATE)
.map((offset) => `${handle.at}: ${offset}`)
)
expect(offenders).toEqual([])
})
it('gives every toggling handle both ends of the pair', () => {
const pairs = (on: string[], off: string[]): boolean =>
on.length === 1 && on[0] === ON_STATE && off.length === 1 && off[0] === OFF_STATE
const offenders = scanRenderer()
.toggles.filter(({ branches: [a, b] }) => !pairs(a, b) && !pairs(b, a))
.map(
({ at, branches }) => `${at}: ${branches.map((side) => side.join(' ') || '—').join(' / ')}`
)
expect(offenders).toEqual([])
})
it('keeps every track on the geometry the 18px on-state is derived from', () => {
expect(scanRenderer().drifted).toEqual([])
})
it('keeps every track a knob the scan can see', () => {
expect(scanRenderer().knobless).toEqual([])
})
})
@@ -0,0 +1,30 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { Switch, SwitchIndicator } from './switch'
describe('Switch', () => {
it('renders the checked state with symmetric track geometry', () => {
const html = renderToStaticMarkup(<Switch checked aria-label="Example setting" />)
expect(html).toContain('role="switch"')
expect(html).toContain('aria-checked="true"')
expect(html).toContain('p-0.5')
expect(html).toContain('data-[state=checked]:translate-x-4')
})
it('renders a visual-only indicator without switch semantics', () => {
const html = renderToStaticMarkup(<SwitchIndicator checked={false} />)
expect(html).toContain('aria-hidden="true"')
expect(html).toContain('data-state="unchecked"')
expect(html).not.toContain('role="switch"')
})
it('renders compact indicator geometry', () => {
const html = renderToStaticMarkup(<SwitchIndicator checked size="compact" />)
expect(html).toContain('h-3.5 w-6')
expect(html).toContain('size-2.5')
expect(html).toContain('data-[state=checked]:translate-x-2.5')
})
})
+67
View File
@@ -0,0 +1,67 @@
import * as React from 'react'
import { Switch as SwitchPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
const trackClassName =
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent p-0.5 transition-colors data-[state=checked]:bg-foreground data-[state=unchecked]:bg-muted-foreground/30'
const thumbClassName =
'pointer-events-none block size-3.5 translate-x-0 rounded-full bg-background shadow-sm transition-transform data-[state=checked]:translate-x-4'
const compactTrackClassName = 'h-3.5 w-6 border-0 p-0.5'
const compactThumbClassName = 'size-2.5 data-[state=checked]:translate-x-2.5'
type SwitchProps = React.ComponentProps<typeof SwitchPrimitive.Root> & {
thumbClassName?: string
}
function Switch({ className, thumbClassName: thumbClasses, ...props }: SwitchProps) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
trackClassName,
'cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(thumbClassName, thumbClasses)}
/>
</SwitchPrimitive.Root>
)
}
type SwitchIndicatorProps = Omit<React.ComponentProps<'span'>, 'children'> & {
checked: boolean
size?: 'default' | 'compact'
thumbClassName?: string
}
function SwitchIndicator({
checked,
className,
size = 'default',
thumbClassName: thumbClasses,
...props
}: SwitchIndicatorProps) {
const state = checked ? 'checked' : 'unchecked'
return (
<span
aria-hidden="true"
data-slot="switch-indicator"
data-state={state}
className={cn(trackClassName, size === 'compact' && compactTrackClassName, className)}
{...props}
>
<span
data-slot="switch-thumb"
data-state={state}
className={cn(thumbClassName, size === 'compact' && compactThumbClassName, thumbClasses)}
/>
</span>
)
}
export { Switch, SwitchIndicator }