From 140eb8aebfbf281a3a7e68d569ff76a2ad4b0536 Mon Sep 17 00:00:00 2001 From: jordanarldt Date: Thu, 20 Mar 2025 13:30:52 -0500 Subject: [PATCH] feat: Add Switch form component --- apps/web/package.json | 1 + apps/web/vibes/soul/docs/switch.mdx | 56 +++++ apps/web/vibes/soul/examples.ts | 7 + .../vibes/soul/examples/form/switch/index.tsx | 31 +++ apps/web/vibes/soul/form/switch/index.tsx | 231 ++++++++++++++++++ apps/web/vibes/soul/navigation.ts | 6 + pnpm-lock.yaml | 31 +++ 7 files changed, 363 insertions(+) create mode 100644 apps/web/vibes/soul/docs/switch.mdx create mode 100644 apps/web/vibes/soul/examples/form/switch/index.tsx create mode 100644 apps/web/vibes/soul/form/switch/index.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 20297ce77..74762223d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-portal": "1.1.2", "@radix-ui/react-radio-group": "1.2.1", "@radix-ui/react-select": "2.1.2", + "@radix-ui/react-switch": "1.1.3", "@radix-ui/react-slot": "1.1.0", "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toast": "1.2.2", diff --git a/apps/web/vibes/soul/docs/switch.mdx b/apps/web/vibes/soul/docs/switch.mdx new file mode 100644 index 000000000..e68191055 --- /dev/null +++ b/apps/web/vibes/soul/docs/switch.mdx @@ -0,0 +1,56 @@ +--- +title: Switch +preview: switch-example +previewSize: xs +--- + +## Usage + +{/* prettier-ignore-start */} + +{` +import { Switch } from '@/vibes/soul/form/switch'; + +function Usage() { + return ( + + ); +} +`} + + +{/* prettier-ignore-end */} + +## API Reference + +### SwitchProps + +| Prop | Type | Default | +| ----------------- | --------------------------------------------- | --------- | +| `name` | `string` | | +| `required` | `boolean` | | +| `variant` | `'primary' \| 'secondary' \| 'tertiary'` | | +| `size` | `'large' \| 'medium' \| 'small'` | | +| `labelPosition` | `'left' \| 'right' \| 'both'` | | +| `label` | `string \| { on: string; off: string }` | | +| `checked` | `boolean` | | +| `defaultChecked` | `boolean` | | +| `onCheckedChange` | `(checked: boolean) => void \| Promise` | | +| `disabled` | `boolean` | | +| `loading` | `boolean` | | + +### CSS Variables + +This component supports various CSS variables for theming. Here's a comprehensive list. + +```css +:root { + --switch-primary-background: hsl(var(--primary)); + --switch-secondary-background: hsl(var(--foreground)); + --switch-tertiary-background: hsl(var(--background)); + --switch-tertiary-border: hsl(var(--contrast-200)); +} +``` diff --git a/apps/web/vibes/soul/examples.ts b/apps/web/vibes/soul/examples.ts index 55917156b..b663e4241 100644 --- a/apps/web/vibes/soul/examples.ts +++ b/apps/web/vibes/soul/examples.ts @@ -1142,6 +1142,13 @@ export const examples = [ files: ['examples/form/swatch-radio-group/index.tsx'], component: lazy(() => import('./examples/form/swatch-radio-group')), }, + { + name: 'switch-example', + dependencies: [], + registryDependencies: ['switch'], + files: ['examples/form/switch/index.tsx'], + component: lazy(() => import('./examples/form/switch')), + }, { name: 'textarea-example', dependencies: [], diff --git a/apps/web/vibes/soul/examples/form/switch/index.tsx b/apps/web/vibes/soul/examples/form/switch/index.tsx new file mode 100644 index 000000000..411638576 --- /dev/null +++ b/apps/web/vibes/soul/examples/form/switch/index.tsx @@ -0,0 +1,31 @@ +import { Switch } from '@/vibes/soul/form/switch'; + +const variants = ['primary', 'secondary', 'tertiary'] as const; +const labelPositions = ['left', 'right', 'both'] as const; + +export default function Preview() { + return ( +
+
+ {labelPositions.map((pos) => ( +
+
+ labelPosition: {pos} +
+ {variants.map((variant) => ( +
+ +
+ ))} +
+ ))} +
+
+ ); +} diff --git a/apps/web/vibes/soul/form/switch/index.tsx b/apps/web/vibes/soul/form/switch/index.tsx new file mode 100644 index 000000000..6bd50cee3 --- /dev/null +++ b/apps/web/vibes/soul/form/switch/index.tsx @@ -0,0 +1,231 @@ +import * as SwitchPrimitive from '@radix-ui/react-switch'; +import { clsx } from 'clsx'; +import { Loader2 } from 'lucide-react'; +import { useId } from 'react'; + +import * as Skeleton from '@/vibes/soul/primitives/skeleton'; + +interface SwitchProps { + name?: string; + required?: boolean; + variant?: 'primary' | 'secondary' | 'tertiary'; + size?: 'large' | 'medium' | 'small'; + labelPosition?: 'left' | 'right' | 'both'; + label?: string | { on: string; off: string }; + checked?: boolean; + defaultChecked?: boolean; + onCheckedChange?: (checked: boolean) => void | Promise; + disabled?: boolean; + loading?: boolean; +} + +/** + * This component supports various CSS variables for theming. Here's a comprehensive list, + * along with their default values: + * + * ```css + * :root { + * --switch-primary-background: hsl(var(--primary)); + * --switch-secondary-background: hsl(var(--foreground)); + * --switch-tertiary-background: hsl(var(--background)); + * --switch-tertiary-border: hsl(var(--contrast-200)); + * } + * ``` + */ +export const Switch = ({ + name, + variant = 'primary', + size = 'medium', + labelPosition = 'right', + label, + disabled, + loading, + checked, + defaultChecked, + onCheckedChange, +}: SwitchProps) => { + const id = useId(); + const hasLabel = label != null && label !== ''; + + return ( +
+ {(labelPosition === 'left' || labelPosition === 'both') && hasLabel && ( + + )} + + + + + + + + {(labelPosition === 'right' || labelPosition === 'both') && hasLabel && ( + + )} +
+ ); +}; + +interface LabelProps { + id: string; + label: string | { on: string; off: string }; + size: 'large' | 'medium' | 'small'; + state?: 'off' | 'on'; + loading?: boolean; +} + +function SwitchLabel({ id, label, size = 'medium', state, loading }: LabelProps) { + const baseClass = + '[&:not([data-loading])]:group-has-[[data-disabled]]/switch:text-contrast-400 font-semibold select-none'; + + if (typeof label === 'string') { + return ( + + ); + } + + if (state) { + return ( + + ); + } + + return ( +
+ + +
+ ); +} + +export function SwitchSkeleton({ + size = 'medium', + labelPosition = 'right', +}: Pick) { + return ( +
+ {(labelPosition === 'left' || labelPosition === 'both') && ( + + )} + + {(labelPosition === 'right' || labelPosition === 'both') && ( + + )} +
+ ); +} diff --git a/apps/web/vibes/soul/navigation.ts b/apps/web/vibes/soul/navigation.ts index 8b4cfdc4b..612d45c93 100644 --- a/apps/web/vibes/soul/navigation.ts +++ b/apps/web/vibes/soul/navigation.ts @@ -466,6 +466,12 @@ const formPages: [Page, ...Page[]] = [ file: 'docs/swatch-radio-group.mdx', component: 'swatch-radio-group', }, + { + title: 'Switch', + slug: 'switch', + file: 'docs/switch.mdx', + component: 'switch', + }, { title: 'Textarea', slug: 'textarea', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b78203a5..eeb57bcfa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: '@radix-ui/react-slot': specifier: 1.1.0 version: 1.1.0(react@19.0.0-rc-603e6108-20241029)(types-react@19.0.0-rc.1) + '@radix-ui/react-switch': + specifier: 1.1.3 + version: 1.1.3(react-dom@19.0.0-rc-603e6108-20241029(react@19.0.0-rc-603e6108-20241029))(react@19.0.0-rc-603e6108-20241029)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) '@radix-ui/react-tabs': specifier: 1.1.1 version: 1.1.1(react-dom@19.0.0-rc-603e6108-20241029(react@19.0.0-rc-603e6108-20241029))(react@19.0.0-rc-603e6108-20241029)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) @@ -1494,6 +1497,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.1.3': + resolution: {integrity: sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tabs@1.1.1': resolution: {integrity: sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==} peerDependencies: @@ -6938,6 +6954,21 @@ snapshots: optionalDependencies: '@types/react': types-react@19.0.0-rc.1 + '@radix-ui/react-switch@1.1.3(react-dom@19.0.0-rc-603e6108-20241029(react@19.0.0-rc-603e6108-20241029))(react@19.0.0-rc-603e6108-20241029)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(react@19.0.0-rc-603e6108-20241029)(types-react@19.0.0-rc.1) + '@radix-ui/react-context': 1.1.1(react@19.0.0-rc-603e6108-20241029)(types-react@19.0.0-rc.1) + '@radix-ui/react-primitive': 2.0.2(react-dom@19.0.0-rc-603e6108-20241029(react@19.0.0-rc-603e6108-20241029))(react@19.0.0-rc-603e6108-20241029)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-controllable-state': 1.1.0(react@19.0.0-rc-603e6108-20241029)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-previous': 1.1.0(react@19.0.0-rc-603e6108-20241029)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-size': 1.1.0(react@19.0.0-rc-603e6108-20241029)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-603e6108-20241029 + react-dom: 19.0.0-rc-603e6108-20241029(react@19.0.0-rc-603e6108-20241029) + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + '@types/react-dom': types-react-dom@19.0.0-rc.1 + '@radix-ui/react-tabs@1.1.1(react-dom@19.0.0-rc-603e6108-20241029(react@19.0.0-rc-603e6108-20241029))(react@19.0.0-rc-603e6108-20241029)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1)': dependencies: '@radix-ui/primitive': 1.1.0