Skip to content

Commit 6bbe654

Browse files
committed
feat: add Switch component with customizable shapes and variants
1 parent 05c985c commit 6bbe654

File tree

6 files changed

+292
-0
lines changed

6 files changed

+292
-0
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { AriaAttributes, HTMLAttributes } from 'react'
2+
import { AnimatePresence, motion } from 'framer-motion'
3+
4+
import { Tooltip } from '@Common/Tooltip'
5+
6+
import { Icon } from '../Icon'
7+
import { SwitchProps } from './types'
8+
import { getSwitchContainerClass, getSwitchIconColor, getSwitchThumbClass, getSwitchTrackColor } from './utils'
9+
10+
const Switch = ({
11+
ariaLabel,
12+
dataTestId,
13+
isDisabled,
14+
isLoading,
15+
isChecked,
16+
tooltipContent,
17+
shape = 'rounded',
18+
variant = 'positive',
19+
iconColor,
20+
iconName,
21+
indeterminate = false,
22+
handleChange,
23+
}: SwitchProps) => {
24+
const getAriaChecked = (): AriaAttributes['aria-checked'] => {
25+
if (!isChecked) {
26+
return false
27+
}
28+
29+
return indeterminate ? 'mixed' : true
30+
}
31+
32+
const ariaChecked = getAriaChecked()
33+
34+
const showIndeterminateIcon = ariaChecked === 'mixed'
35+
const role: HTMLAttributes<HTMLButtonElement>['role'] = showIndeterminateIcon ? 'checkbox' : 'switch'
36+
37+
const renderContent = () => {
38+
if (isLoading) {
39+
return <Icon name="ic-circle-loader" color={null} />
40+
}
41+
42+
return (
43+
<motion.span
44+
className={`p-1 flex flex-grow-1 ${shape === 'rounded' ? 'br-12' : 'br-4'} ${isChecked ? 'right' : 'left'}`}
45+
layout
46+
animate={{
47+
backgroundColor: getSwitchTrackColor({ shape, variant, isChecked }),
48+
}}
49+
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
50+
>
51+
<motion.span
52+
className={getSwitchThumbClass({ indeterminate, shape, isChecked })}
53+
layout
54+
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
55+
>
56+
<AnimatePresence>
57+
{showIndeterminateIcon ? (
58+
<motion.span
59+
key="dash"
60+
className="w-8 h-2 br-4 dc__no-shrink bg__white"
61+
initial={{ scale: 0, opacity: 0 }}
62+
animate={{ scale: 1, opacity: 1 }}
63+
exit={{ scale: 0, opacity: 0 }}
64+
/>
65+
) : (
66+
iconName && (
67+
<motion.span
68+
key="icon"
69+
className="flex icon-dim-12 dc__fill-available-space dc__no-shrink"
70+
initial={{ scale: 0.8, opacity: 0 }}
71+
animate={{ scale: 1, opacity: 1 }}
72+
exit={{ scale: 0.8, opacity: 0 }}
73+
>
74+
<Icon
75+
name={iconName}
76+
color={getSwitchIconColor({
77+
isChecked,
78+
iconColor,
79+
variant,
80+
})}
81+
size={12}
82+
/>
83+
</motion.span>
84+
)
85+
)}
86+
</AnimatePresence>
87+
</motion.span>
88+
</motion.span>
89+
)
90+
}
91+
92+
// TODO: Can add hidden input for accessibility in case name [for forms] is given
93+
return (
94+
<Tooltip alwaysShowTippyOnHover={!!tooltipContent} content={tooltipContent}>
95+
<div className={`${getSwitchContainerClass({ shape })} flex dc__no-shrink`}>
96+
<button
97+
type="button"
98+
role={role}
99+
aria-checked={ariaChecked}
100+
aria-label={isLoading ? 'Loading...' : ariaLabel}
101+
data-testid={dataTestId}
102+
disabled={isDisabled || isLoading}
103+
className={`p-0-imp h-100 flex flex-grow-1 dc__transparent ${isDisabled ? 'dc__disabled' : ''} dc__fill-available-space`}
104+
onClick={handleChange}
105+
>
106+
{renderContent()}
107+
</button>
108+
</div>
109+
</Tooltip>
110+
)
111+
}
112+
113+
export default Switch
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { SwitchProps } from './types'
2+
3+
export const SWITCH_VARIANTS: Record<SwitchProps['variant'], null> = {
4+
theme: null,
5+
positive: null,
6+
}
7+
8+
export const SWITCH_SHAPES: Record<SwitchProps['shape'], null> = {
9+
rounded: null,
10+
square: null,
11+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as Switch } from './Switch.component'
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { IconBaseColorType } from '@Shared/types'
2+
3+
import { IconName } from '../Icon'
4+
5+
/**
6+
* Represents the properties for configuring the shape and behavior of a switch component.
7+
*
8+
* - When `shape` is `rounded`:
9+
* - The switch will have a rounded appearance.
10+
* - `iconName`, `iconColor`, and `indeterminate` are not applicable.
11+
*
12+
* - When `shape` is `square`:
13+
* - The switch will have a square appearance.
14+
* - `iconName` specifies the name of the icon to display.
15+
* - `iconColor` allows customization of the icon's color in the active state.
16+
* - `indeterminate` indicates whether the switch is in an indeterminate state, typically used for checkboxes to represent a mixed state.
17+
* If `indeterminate` is true, the switch will not be fully checked or unchecked, and its role will change to `checkbox`.
18+
*/
19+
type SwitchShapeProps =
20+
| {
21+
/**
22+
* The shape of the switch. Defaults to `rounded` if not specified.
23+
*/
24+
shape?: 'rounded'
25+
26+
/**
27+
* Icon name is not applicable for the `rounded` shape.
28+
*/
29+
iconName?: never
30+
31+
/**
32+
* Icon color is not applicable for the `rounded` shape.
33+
*/
34+
iconColor?: never
35+
indeterminate?: never
36+
}
37+
| {
38+
/**
39+
* The shape of the switch. Must be `square` to enable icon-related properties.
40+
*/
41+
shape: 'square'
42+
43+
/**
44+
* The name of the icon to display when the shape is `square`.
45+
*/
46+
iconName: IconName
47+
48+
/**
49+
* The color of the icon. If provided, this will override the default color in the active state.
50+
*/
51+
iconColor?: IconBaseColorType
52+
/**
53+
* Indicates whether the switch is in an indeterminate state.
54+
* This state is typically used for checkboxes to indicate a mixed state.
55+
* If true, the switch will not be fully checked or unchecked. We will change the role to checkbox in this case since the indeterminate state is not applicable for the switch.
56+
* This property is not applicable for the `rounded` shape.
57+
* @default false
58+
*/
59+
indeterminate?: boolean
60+
}
61+
62+
/**
63+
* Represents the properties for the `Switch` component.
64+
*/
65+
export type SwitchProps = {
66+
/**
67+
* The ARIA label for the switch, used for accessibility purposes.
68+
*/
69+
ariaLabel: string
70+
71+
/**
72+
* A unique identifier for testing purposes.
73+
*/
74+
dataTestId: string
75+
76+
/**
77+
* The visual variant of the switch.
78+
*
79+
* @default `positive`
80+
*/
81+
variant?: 'theme' | 'positive'
82+
83+
handleChange: () => void
84+
85+
/**
86+
* Indicates whether the switch is disabled.
87+
*/
88+
isDisabled?: boolean
89+
90+
/**
91+
* Indicates whether the switch is in a loading state.
92+
*/
93+
isLoading?: boolean
94+
95+
/**
96+
* Indicates whether the switch is currently checked (on).
97+
*/
98+
isChecked: boolean
99+
100+
/**
101+
* Optional tooltip content to display when hovering over the switch.
102+
*
103+
* @default undefined
104+
*/
105+
tooltipContent?: string
106+
} & SwitchShapeProps
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { IconBaseColorType } from '@Shared/types'
2+
3+
import { SwitchProps } from './types'
4+
5+
// On intro of size there will changes in almost all methods
6+
export const getSwitchContainerClass = ({ shape }: Required<Pick<SwitchProps, 'shape'>>): string => {
7+
if (shape === 'rounded') {
8+
return 'py-3 h-24 w-20'
9+
}
10+
11+
return 'w-28 h-18'
12+
}
13+
14+
export const getSwitchTrackColor = ({
15+
shape,
16+
variant,
17+
isChecked,
18+
}: Required<Pick<SwitchProps, 'shape' | 'variant' | 'isChecked'>>): `var(--${IconBaseColorType})` => {
19+
if (!isChecked) {
20+
return 'var(--N200)'
21+
}
22+
23+
if (shape === 'rounded') {
24+
if (variant === 'theme') {
25+
return 'var(--B500)'
26+
}
27+
28+
return 'var(--G500)'
29+
}
30+
31+
if (variant === 'theme') {
32+
return 'var(--B300)'
33+
}
34+
35+
return 'var(--G300)'
36+
}
37+
38+
export const getSwitchThumbClass = ({
39+
indeterminate,
40+
shape,
41+
isChecked,
42+
}: Pick<SwitchProps, 'indeterminate' | 'shape' | 'isChecked'>) => {
43+
if (isChecked && indeterminate) {
44+
return 'w-100 h-100 flex'
45+
}
46+
47+
return `flex p-2 ${shape === 'rounded' ? 'dc__border-radius-50-per icon-dim-10' : 'br-3'} bg__white`
48+
}
49+
50+
export const getSwitchIconColor = ({
51+
iconColor,
52+
isChecked,
53+
variant,
54+
}: Pick<SwitchProps, 'iconColor' | 'isChecked' | 'variant'>): IconBaseColorType => {
55+
if (!isChecked) {
56+
return 'N200'
57+
}
58+
59+
return iconColor || (variant === 'theme' ? 'B500' : 'G500')
60+
}

src/Shared/Components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export * from './SelectPicker'
8787
export * from './ShowMoreText'
8888
export * from './SSOProviderIcon'
8989
export * from './StatusComponent'
90+
export * from './Switch'
9091
export * from './TabGroup'
9192
export * from './Table'
9293
export * from './TagsKeyValueTable'

0 commit comments

Comments
 (0)