feat(ui): replace Switch with TogglePill per design audit (#687)

* chore: ignore local design-system references

Adds frontend/DESIGN.md and .design-bundle/ to .gitignore so the
design-system reference and the audit handoff bundle stay local
to each contributor's machine.

* feat(ui): replace Switch with TogglePill per design audit

Adds a mono-uppercase ON/OFF pill that replaces the sliding Switch
across every call site. The pill uses role="switch" plus aria-checked,
tints success-green when on and neutral card-tone when off, and holds
a stable 60px min-width so the label flip does not shift layout. It
reinforces the tracked-mono uppercase pattern already used for kicker
labels and table column headers, and echoes the UP/DN status language
of the sidebar stack list.

- Add TogglePill component (frontend/src/components/ui/toggle-pill.tsx)
- Replace 17 Switch call sites across 10 files (settings sections,
  SSO providers, scheduled operations, network topology, resources,
  stack auto-heal)
- Remove unused Switch wrapper and radix primitive
- Drop the @radix-ui/react-switch dependency
This commit is contained in:
Anso
2026-04-18 22:15:22 -04:00
committed by GitHub
parent bd94ef9e15
commit 88ec71fcaa
17 changed files with 85 additions and 232 deletions
@@ -1,156 +0,0 @@
/* eslint-disable */
'use client';
import * as React from 'react';
import { Switch as SwitchPrimitives } from 'radix-ui';
import {
motion,
type TargetAndTransition,
type VariantLabels,
type HTMLMotionProps,
type LegacyAnimationControls,
} from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type SwitchContextType = {
isChecked: boolean;
setIsChecked: (isChecked: boolean) => void;
isPressed: boolean;
setIsPressed: (isPressed: boolean) => void;
};
const [SwitchProvider, useSwitch] =
getStrictContext<SwitchContextType>('SwitchContext');
type SwitchProps = Omit<
React.ComponentProps<typeof SwitchPrimitives.Root>,
'asChild'
> &
HTMLMotionProps<'button'>;
function Switch(props: SwitchProps) {
// Destructure Radix-only props so they don't leak onto the motion.button DOM element
const {
checked,
defaultChecked,
onCheckedChange,
disabled,
required,
name,
value,
form,
...motionProps
} = props;
const [isPressed, setIsPressed] = React.useState(false);
const [isChecked, setIsChecked] = useControlledState({
value: checked,
defaultValue: defaultChecked,
onChange: onCheckedChange,
});
return (
<SwitchProvider
value={{ isChecked, setIsChecked, isPressed, setIsPressed }}
>
<SwitchPrimitives.Root
checked={checked}
defaultChecked={defaultChecked}
onCheckedChange={setIsChecked}
disabled={disabled}
required={required}
name={name}
value={value}
form={form}
asChild
>
<motion.button
data-slot="switch"
whileTap="tap"
initial={false}
onTapStart={() => setIsPressed(true)}
onTapCancel={() => setIsPressed(false)}
onTap={() => setIsPressed(false)}
{...motionProps}
/>
</SwitchPrimitives.Root>
</SwitchProvider>
);
}
type SwitchThumbProps = Omit<
React.ComponentProps<typeof SwitchPrimitives.Thumb>,
'asChild'
> &
HTMLMotionProps<'div'> & {
pressedAnimation?:
| TargetAndTransition
| VariantLabels
| boolean
| LegacyAnimationControls;
};
function SwitchThumb({
pressedAnimation,
transition = { type: 'spring', stiffness: 300, damping: 25 },
...props
}: SwitchThumbProps) {
const { isPressed } = useSwitch();
return (
<SwitchPrimitives.Thumb asChild>
<motion.div
data-slot="switch-thumb"
whileTap="tab"
layout
transition={transition}
animate={isPressed ? pressedAnimation : undefined}
{...props}
/>
</SwitchPrimitives.Thumb>
);
}
type SwitchIconPosition = 'left' | 'right' | 'thumb';
type SwitchIconProps = HTMLMotionProps<'div'> & {
position: SwitchIconPosition;
};
function SwitchIcon({
position,
transition = { type: 'spring', bounce: 0 },
...props
}: SwitchIconProps) {
const { isChecked } = useSwitch();
const isAnimated = React.useMemo(() => {
if (position === 'right') return !isChecked;
if (position === 'left') return isChecked;
if (position === 'thumb') return true;
return false;
}, [position, isChecked]);
return (
<motion.div
data-slot={`switch-${position}-icon`}
animate={isAnimated ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}
transition={transition}
{...props}
/>
);
}
export {
Switch,
SwitchThumb,
SwitchIcon,
useSwitch,
type SwitchProps,
type SwitchThumbProps,
type SwitchIconProps,
type SwitchIconPosition,
type SwitchContextType,
};