@12-apps/ui 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
  3. package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
  4. package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
  5. package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
  6. package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
  7. package/src/components/data-display/Carousel/Carousel.tsx +84 -732
  8. package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
  9. package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
  10. package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
  11. package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
  12. package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
  13. package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
  14. package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
  15. package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
  16. package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
  17. package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
  18. package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
  19. package/src/components/data-display/Sheet/Sheet.tsx +125 -948
  20. package/src/components/feedback/Modal/Modal.styles.ts +126 -0
  21. package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
  22. package/src/components/feedback/Modal/Modal.tsx +37 -242
  23. package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
  24. package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
  25. package/src/components/form/InputOTP/InputOTP.tsx +74 -221
  26. package/src/components/form/Label/Label.parts.tsx +75 -0
  27. package/src/components/form/Label/Label.styles.ts +206 -0
  28. package/src/components/form/Label/Label.tsx +73 -325
  29. package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
  30. package/src/components/form/Menubar/Menubar.item.tsx +121 -0
  31. package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
  32. package/src/components/form/Menubar/Menubar.styles.ts +110 -0
  33. package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
  34. package/src/components/form/Menubar/Menubar.tsx +64 -347
  35. package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
  36. package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
  37. package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
  38. package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
  39. package/src/components/form/Slider/Slider.animations.ts +40 -0
  40. package/src/components/form/Slider/Slider.styles.ts +292 -0
  41. package/src/components/form/Slider/Slider.tsx +106 -357
  42. package/src/components/form/Switch/Switch.animations.ts +62 -0
  43. package/src/components/form/Switch/Switch.parts.tsx +213 -0
  44. package/src/components/form/Switch/Switch.styles.ts +355 -0
  45. package/src/components/form/Switch/Switch.tsx +87 -566
  46. package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
  47. package/src/components/typography/Heading/Heading.styles.ts +101 -0
  48. package/src/components/typography/Heading/Heading.tsx +48 -164
  49. package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
  50. package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
  51. package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
  52. package/src/utils/index.ts +1 -0
  53. package/src/utils/withDefaults.ts +20 -0
@@ -0,0 +1,213 @@
1
+ import { Box, FormHelperText, Switch as MuiSwitch, Typography } from '@mui/material';
2
+ import { styled } from '@mui/material/styles';
3
+ import React from 'react';
4
+
5
+ import { switchSx } from './Switch.styles';
6
+ import type { SwitchFlags } from './Switch.styles';
7
+ import type { SwitchProps } from './Switch.types';
8
+
9
+ const StyledSwitch = styled(MuiSwitch, {
10
+ shouldForwardProp: (prop) =>
11
+ ![
12
+ 'customVariant',
13
+ 'customColor',
14
+ 'customSize',
15
+ 'glow',
16
+ 'glass',
17
+ 'gradient',
18
+ 'trackWidth',
19
+ 'trackHeight',
20
+ 'onText',
21
+ 'offText',
22
+ 'loading',
23
+ 'ripple',
24
+ 'pulse',
25
+ ].includes(prop as string),
26
+ })<SwitchFlags>(({ theme, ...flags }) => ({ ...switchSx(theme, flags) }));
27
+
28
+ /**
29
+ * A top or bottom label stacks and aligns to the start; start/end sit the label
30
+ * beside the control and centre it.
31
+ */
32
+ const StyledLabelContainer = styled(Box, {
33
+ shouldForwardProp: (prop) => !['labelPosition', 'error'].includes(prop as string),
34
+ })<{ labelPosition?: string; error?: boolean }>(({ theme, labelPosition, error }) => {
35
+ const stacked = labelPosition === 'top' || labelPosition === 'bottom';
36
+
37
+ return {
38
+ display: 'flex',
39
+ alignItems: stacked ? 'flex-start' : 'center',
40
+ flexDirection:
41
+ labelPosition === 'top' ? 'column' : labelPosition === 'bottom' ? 'column-reverse' : 'row',
42
+ gap: theme.spacing(stacked ? 1 : 2),
43
+ width: '100%',
44
+ ...(error && { '& .MuiTypography-root': { color: theme.palette.error.main } }),
45
+ };
46
+ });
47
+
48
+ /**
49
+ * A plain wrapper over the styled row. Exporting the styled component itself
50
+ * needs a @mui/system reference tsc calls unportable (TS2742).
51
+ */
52
+ export const LabelContainer: React.FC<{
53
+ labelPosition?: string;
54
+ error?: boolean;
55
+ children: React.ReactNode;
56
+ }> = ({ labelPosition, error, children }) => (
57
+ <StyledLabelContainer labelPosition={labelPosition} error={error}>
58
+ {children}
59
+ </StyledLabelContainer>
60
+ );
61
+
62
+ const ICON_SIZES: Record<string, number> = { xs: 12, sm: 14, md: 16, lg: 18, xl: 20 };
63
+
64
+ export interface SwitchIconProps {
65
+ icon: React.ReactNode;
66
+ /** True when this icon is the one the current state should show. */
67
+ shown: boolean;
68
+ animated: boolean;
69
+ size: string;
70
+ side: 'on' | 'off';
71
+ }
72
+
73
+ /**
74
+ * One of the two icons overlaid on the track. The on icon slides to the left
75
+ * edge when checked and the off icon to the right when unchecked; they are
76
+ * mirror images, so they share this component rather than being written twice.
77
+ */
78
+ export const SwitchIcon: React.FC<SwitchIconProps> = ({ icon, shown, animated, size, side }) => {
79
+ const isOn = side === 'on';
80
+ const translate = isOn ? 'translate(-50%, -50%)' : 'translate(50%, -50%)';
81
+
82
+ return (
83
+ <Box
84
+ sx={{
85
+ position: 'absolute',
86
+ top: '50%',
87
+ ...(isOn ? { left: shown ? 4 : '50%' } : { right: shown ? 4 : '50%' }),
88
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
89
+ opacity: shown ? 1 : 0,
90
+ transform: `${translate} scale(${shown && animated ? 1 : 0.8})`,
91
+ pointerEvents: 'none',
92
+ zIndex: 2,
93
+ color: isOn ? '#fff' : 'text.secondary',
94
+ fontSize: ICON_SIZES[size] ?? ICON_SIZES.md,
95
+ }}
96
+ >
97
+ {icon}
98
+ </Box>
99
+ );
100
+ };
101
+
102
+ export interface SwitchControlProps extends SwitchFlags {
103
+ checked?: boolean;
104
+ onChange?: SwitchProps['onChange'];
105
+ onIcon?: React.ReactNode;
106
+ offIcon?: React.ReactNode;
107
+ animated: boolean;
108
+ size: string;
109
+ dataTestId?: string;
110
+ switchRef?: React.Ref<HTMLButtonElement>;
111
+ rest: Record<string, unknown>;
112
+ }
113
+
114
+ /** The control itself: the track, and the two icons that may sit over it. */
115
+ export const SwitchControl: React.FC<SwitchControlProps> = ({
116
+ checked,
117
+ onChange,
118
+ onIcon,
119
+ offIcon,
120
+ animated,
121
+ size,
122
+ dataTestId,
123
+ switchRef,
124
+ rest,
125
+ ...flags
126
+ }) => {
127
+ const props = rest as Record<string, unknown> & {
128
+ disabled?: boolean;
129
+ inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
130
+ };
131
+
132
+ return (
133
+ <Box sx={{ position: 'relative', display: 'inline-flex' }}>
134
+ <StyledSwitch
135
+ ref={switchRef}
136
+ {...flags}
137
+ checked={checked}
138
+ onChange={onChange}
139
+ disabled={Boolean(flags.loading) || props.disabled}
140
+ inputProps={
141
+ {
142
+ 'aria-label': props['aria-label'],
143
+ 'aria-describedby': props['aria-describedby'],
144
+ ...props.inputProps,
145
+ 'data-testid': dataTestId || 'switch',
146
+ } as React.InputHTMLAttributes<HTMLInputElement>
147
+ }
148
+ {...props}
149
+ />
150
+
151
+ {onIcon && (
152
+ <SwitchIcon
153
+ icon={onIcon}
154
+ shown={Boolean(checked)}
155
+ animated={animated}
156
+ size={size}
157
+ side="on"
158
+ />
159
+ )}
160
+ {offIcon && (
161
+ <SwitchIcon
162
+ icon={offIcon}
163
+ shown={!checked}
164
+ animated={animated}
165
+ size={size}
166
+ side="off"
167
+ />
168
+ )}
169
+ </Box>
170
+ );
171
+ };
172
+
173
+ export const SwitchHelper: React.FC<{
174
+ helperText?: React.ReactNode;
175
+ error?: boolean;
176
+ dataTestId?: string;
177
+ }> = ({ helperText, error, dataTestId }) =>
178
+ helperText ? (
179
+ <FormHelperText
180
+ error={error}
181
+ sx={{ mt: 1 }}
182
+ data-testid={dataTestId ? `${dataTestId}-helper` : 'switch-helper'}
183
+ >
184
+ {helperText}
185
+ </FormHelperText>
186
+ ) : null;
187
+
188
+ export const SwitchLabel: React.FC<{
189
+ label: React.ReactNode;
190
+ description?: React.ReactNode;
191
+ error?: boolean;
192
+ dataTestId?: string;
193
+ }> = ({ label, description, error, dataTestId }) => (
194
+ <Box sx={{ flex: 1 }}>
195
+ <Typography
196
+ variant="body2"
197
+ fontWeight={500}
198
+ color={error ? 'error.main' : 'text.primary'}
199
+ data-testid={dataTestId ? `${dataTestId}-label` : 'switch-label'}
200
+ >
201
+ {label}
202
+ </Typography>
203
+ {description && (
204
+ <Typography
205
+ variant="caption"
206
+ color={error ? 'error.main' : 'text.secondary'}
207
+ sx={{ display: 'block', mt: 0.5 }}
208
+ >
209
+ {description}
210
+ </Typography>
211
+ )}
212
+ </Box>
213
+ );
@@ -0,0 +1,355 @@
1
+ import { alpha } from '@mui/material';
2
+ import type { CSSObject, Theme } from '@mui/material/styles';
3
+
4
+ import {
5
+ bounceAnimation,
6
+ glowAnimation,
7
+ pulseAnimation,
8
+ rippleAnimation,
9
+ shimmerAnimation,
10
+ spinAnimation,
11
+ } from './Switch.animations';
12
+
13
+ interface ColorPalette {
14
+ main: string;
15
+ dark?: string;
16
+ light?: string;
17
+ contrastText?: string;
18
+ }
19
+
20
+ /**
21
+ * `neutral` is not a MUI palette entry, so it is built from the grey ramp. The
22
+ * literal fallbacks cover a theme whose grey ramp omits these steps.
23
+ */
24
+ const neutralPalette = (theme: Theme): ColorPalette => ({
25
+ main: theme.palette.grey?.[700] || '#616161',
26
+ dark: theme.palette.grey?.[800] || '#424242',
27
+ light: theme.palette.grey?.[500] || '#9e9e9e',
28
+ contrastText: '#fff',
29
+ });
30
+
31
+ /** `danger` is this component's name for the error palette; the rest map straight through. */
32
+ const namedPalette = (theme: Theme, color: string): ColorPalette => {
33
+ const colorMap: Record<string, ColorPalette> = {
34
+ primary: theme.palette.primary,
35
+ secondary: theme.palette.secondary,
36
+ success: theme.palette.success,
37
+ warning: theme.palette.warning,
38
+ danger: theme.palette.error,
39
+ };
40
+
41
+ return colorMap[color] || theme.palette.primary;
42
+ };
43
+
44
+ /**
45
+ * Resolves a colour name to a full palette, filling any step the theme leaves
46
+ * out from `main` and then from the primary palette.
47
+ */
48
+ const getColorFromTheme = (theme: Theme, color: string): ColorPalette => {
49
+ if (color === 'neutral') {
50
+ return neutralPalette(theme);
51
+ }
52
+
53
+ const palette = namedPalette(theme, color);
54
+ const { primary } = theme.palette;
55
+
56
+ return {
57
+ main: palette.main || primary.main,
58
+ dark: palette.dark || palette.main || primary.dark,
59
+ light: palette.light || palette.main || primary.light,
60
+ contrastText: palette.contrastText || '#fff',
61
+ };
62
+ };
63
+
64
+ type SizeKey = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
65
+
66
+ interface SwitchGeometry {
67
+ width: number;
68
+ height: number;
69
+ padding: number;
70
+ thumbSize: number;
71
+ }
72
+
73
+ const SIZES: Record<SizeKey, SwitchGeometry> = {
74
+ xs: { width: 34, height: 18, padding: 1, thumbSize: 14 },
75
+ sm: { width: 42, height: 22, padding: 1, thumbSize: 18 },
76
+ md: { width: 50, height: 26, padding: 1, thumbSize: 22 },
77
+ lg: { width: 58, height: 30, padding: 2, thumbSize: 24 },
78
+ xl: { width: 66, height: 34, padding: 2, thumbSize: 28 },
79
+ };
80
+
81
+ export interface SwitchFlags {
82
+ customVariant?: string;
83
+ customColor?: string;
84
+ customSize?: string;
85
+ glow?: boolean;
86
+ glass?: boolean;
87
+ gradient?: boolean;
88
+ trackWidth?: number;
89
+ trackHeight?: number;
90
+ onText?: string;
91
+ offText?: string;
92
+ loading?: boolean;
93
+ ripple?: boolean;
94
+ pulse?: boolean;
95
+ }
96
+
97
+ /**
98
+ * The four platform looks. Each names one row of the tables below, so the
99
+ * borderRadius/shadow/colour choices stop being nested ternaries repeated per
100
+ * DOM part.
101
+ */
102
+ type Look = 'ios' | 'android' | 'material' | 'default';
103
+
104
+ const lookOf = (customVariant?: string): Look => {
105
+ if (customVariant === 'ios') return 'ios';
106
+ if (customVariant === 'android') return 'android';
107
+ if (customVariant === 'material') return 'material';
108
+ return 'default';
109
+ };
110
+
111
+ /** Track geometry once the caller's overrides and the size preset are combined. */
112
+ const geometryOf = (flags: SwitchFlags) => {
113
+ const preset = SIZES[flags.customSize as SizeKey] ?? SIZES.md;
114
+
115
+ return {
116
+ ...preset,
117
+ width: flags.trackWidth || preset.width,
118
+ height: flags.trackHeight || preset.height,
119
+ };
120
+ };
121
+
122
+ const THUMB_RADIUS: Record<Look, (thumbSize: number) => number | string> = {
123
+ ios: () => '50%',
124
+ android: () => 4,
125
+ material: (thumbSize) => thumbSize / 3,
126
+ default: () => '50%',
127
+ };
128
+
129
+ const TRACK_RADIUS: Record<Look, (height: number) => number> = {
130
+ ios: (height) => height / 2,
131
+ android: (height) => height / 3,
132
+ material: (height) => height / 2.5,
133
+ default: (height) => height / 2,
134
+ };
135
+
136
+ const thumbShadow = (theme: Theme, look: Look) => {
137
+ if (look === 'ios') {
138
+ return `0 3px 1px 0 ${alpha('#000', 0.04)}, 0 3px 8px 0 ${alpha('#000', 0.12)}, 0 1px 0 0 ${alpha('#000', 0.08)}`;
139
+ }
140
+ return look === 'android' ? theme.shadows[3] : theme.shadows[2];
141
+ };
142
+
143
+ const trackColor = (theme: Theme, look: Look) => {
144
+ if (look === 'ios') return alpha(theme.palette.common.black, 0.1);
145
+ if (look === 'android') return alpha(theme.palette.action.disabled, 0.2);
146
+ return alpha(theme.palette.action.disabled, 0.3);
147
+ };
148
+
149
+ /** The ripple that expands from the thumb on hover. */
150
+ const rippleOverlay = (palette: ColorPalette): CSSObject => ({
151
+ content: '""',
152
+ position: 'absolute',
153
+ top: '50%',
154
+ left: '50%',
155
+ width: '100%',
156
+ height: '100%',
157
+ borderRadius: '50%',
158
+ background: alpha(palette.main, 0.2),
159
+ animation: `${rippleAnimation} 0.6s ease-out`,
160
+ transform: 'translate(-50%, -50%)',
161
+ pointerEvents: 'none',
162
+ });
163
+
164
+ /** The checked track: the filled bar behind the thumb once the switch is on. */
165
+ const checkedTrack = (flags: SwitchFlags, palette: ColorPalette): CSSObject => ({
166
+ backgroundColor: palette.main,
167
+ opacity: 1,
168
+ border: 0,
169
+ position: 'relative',
170
+ overflow: 'hidden',
171
+ ...(flags.glow && {
172
+ animation: `${glowAnimation} 2s ease-in-out infinite`,
173
+ boxShadow: `0 0 10px ${alpha(palette.main, 0.6)}, inset 0 0 10px ${alpha(palette.main, 0.2)}`,
174
+ }),
175
+ ...(flags.gradient && {
176
+ background: `linear-gradient(90deg, ${palette.light || palette.main} 0%, ${palette.main} 50%, ${palette.dark || palette.main} 100%)`,
177
+ backgroundSize: '200% 100%',
178
+ animation: `${shimmerAnimation} 3s ease infinite`,
179
+ }),
180
+ });
181
+
182
+ const switchBaseSx = (
183
+ theme: Theme,
184
+ flags: SwitchFlags,
185
+ palette: ColorPalette,
186
+ geometry: SwitchGeometry,
187
+ look: Look,
188
+ ): CSSObject => {
189
+ const { width, thumbSize, padding } = geometry;
190
+ const isIos = look === 'ios';
191
+
192
+ return {
193
+ padding,
194
+ margin: 0,
195
+ transitionDuration: '300ms',
196
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
197
+ ...(isIos && { transform: 'translateX(2px)' }),
198
+ '&:hover': {
199
+ '& .MuiSwitch-thumb': {
200
+ transform: flags.loading ? 'none' : 'scale(1.05)',
201
+ boxShadow: `${theme.shadows[4]}, 0 0 12px ${alpha(palette.main, 0.2)}`,
202
+ },
203
+ ...(flags.ripple && { '&::after': rippleOverlay(palette) }),
204
+ },
205
+ '&.Mui-checked': {
206
+ // The thumb travels the track minus its own width and both paddings; the
207
+ // iOS look insets differently, so it gets its own distance.
208
+ transform: `translateX(${width - thumbSize - padding * 2}px)`,
209
+ color: '#fff',
210
+ '& .MuiSwitch-thumb': {
211
+ animation: flags.loading ? 'none' : `${bounceAnimation} 0.3s ease-out`,
212
+ },
213
+ '& + .MuiSwitch-track': checkedTrack(flags, palette),
214
+ '&.Mui-disabled + .MuiSwitch-track': { opacity: 0.5 },
215
+ ...(isIos && { transform: `translateX(${width - thumbSize - 4}px)` }),
216
+ },
217
+ '&.Mui-focusVisible .MuiSwitch-thumb': {
218
+ color: palette.main,
219
+ border: `6px solid ${alpha(palette.main, 0.2)}`,
220
+ },
221
+ '&.Mui-disabled .MuiSwitch-thumb': { color: theme.palette.grey[100] },
222
+ '&.Mui-disabled + .MuiSwitch-track': { opacity: 0.3 },
223
+ };
224
+ };
225
+
226
+ const thumbSx = (
227
+ theme: Theme,
228
+ flags: SwitchFlags,
229
+ palette: ColorPalette,
230
+ thumbSize: number,
231
+ look: Look,
232
+ ): CSSObject => ({
233
+ width: thumbSize,
234
+ height: thumbSize,
235
+ borderRadius: THUMB_RADIUS[look](thumbSize),
236
+ backgroundColor: '#fff',
237
+ boxShadow: thumbShadow(theme, look),
238
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
239
+ position: 'relative',
240
+ display: 'flex',
241
+ alignItems: 'center',
242
+ justifyContent: 'center',
243
+ ...(flags.glass && {
244
+ backgroundColor: alpha(theme.palette.background.paper, 0.9),
245
+ backdropFilter: 'blur(10px)',
246
+ border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
247
+ }),
248
+ ...(flags.pulse && { animation: `${pulseAnimation} 2s ease-in-out infinite` }),
249
+ ...(flags.loading && {
250
+ // A spinner drawn inside the thumb rather than over the whole control.
251
+ '&::after': {
252
+ content: '""',
253
+ position: 'absolute',
254
+ width: thumbSize * 0.6,
255
+ height: thumbSize * 0.6,
256
+ border: `2px solid ${palette.main}`,
257
+ borderTop: `2px solid transparent`,
258
+ borderRadius: '50%',
259
+ animation: `${spinAnimation} 1s linear infinite`,
260
+ },
261
+ }),
262
+ ...(look === 'material' && {
263
+ '&::before': {
264
+ content: '""',
265
+ position: 'absolute',
266
+ width: '100%',
267
+ height: '100%',
268
+ borderRadius: 'inherit',
269
+ backgroundColor: palette.main,
270
+ opacity: 0,
271
+ transform: 'scale(0)',
272
+ transition: 'all 0.3s ease',
273
+ },
274
+ }),
275
+ });
276
+
277
+ /** The on/off wording the `label` variant prints inside the track. */
278
+ const trackLabels = (theme: Theme, onText?: string, offText?: string): CSSObject => ({
279
+ display: 'flex',
280
+ alignItems: 'center',
281
+ justifyContent: 'space-between',
282
+ paddingLeft: theme.spacing(1),
283
+ paddingRight: theme.spacing(1),
284
+ fontSize: '0.75rem',
285
+ fontWeight: 500,
286
+ color: theme.palette.text.secondary,
287
+ '&::before, &::after': {
288
+ content: '""',
289
+ position: 'absolute',
290
+ fontSize: '0.75rem',
291
+ fontWeight: 500,
292
+ top: '50%',
293
+ transform: 'translateY(-50%)',
294
+ zIndex: 1,
295
+ },
296
+ ...(onText && {
297
+ '&::before': { content: `"${onText}"`, left: theme.spacing(1), color: '#fff' },
298
+ }),
299
+ ...(offText && {
300
+ '&::after': {
301
+ content: `"${offText}"`,
302
+ right: theme.spacing(1),
303
+ color: theme.palette.text.secondary,
304
+ },
305
+ }),
306
+ });
307
+
308
+ const trackSx = (
309
+ theme: Theme,
310
+ flags: SwitchFlags,
311
+ palette: ColorPalette,
312
+ height: number,
313
+ look: Look,
314
+ ): CSSObject => {
315
+ const { glass, gradient, onText, offText, customVariant } = flags;
316
+ const showsLabels = customVariant === 'label' && (onText || offText);
317
+
318
+ return {
319
+ borderRadius: TRACK_RADIUS[look](height),
320
+ backgroundColor: trackColor(theme, look),
321
+ opacity: 1,
322
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
323
+ position: 'relative',
324
+ boxShadow:
325
+ look === 'ios'
326
+ ? `inset 0 0 0 0.5px ${alpha('#000', 0.1)}, inset 0 2px 3px ${alpha('#000', 0.12)}`
327
+ : 'none',
328
+ ...(glass && {
329
+ backgroundColor: alpha(theme.palette.background.paper, 0.1),
330
+ backdropFilter: 'blur(20px)',
331
+ border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
332
+ }),
333
+ ...(gradient &&
334
+ !glass && {
335
+ background: `linear-gradient(135deg, ${alpha(palette.light || palette.main, 0.3)}, ${alpha(palette.main, 0.2)})`,
336
+ }),
337
+ ...(showsLabels && trackLabels(theme, onText, offText)),
338
+ };
339
+ };
340
+
341
+ export const switchSx = (theme: Theme, flags: SwitchFlags): CSSObject => {
342
+ const palette = getColorFromTheme(theme, flags.customColor ?? 'primary');
343
+ const geometry = geometryOf(flags);
344
+ const look = lookOf(flags.customVariant);
345
+
346
+ return {
347
+ width: geometry.width,
348
+ height: geometry.height,
349
+ padding: 0,
350
+ overflow: 'visible',
351
+ '& .MuiSwitch-switchBase': switchBaseSx(theme, flags, palette, geometry, look),
352
+ '& .MuiSwitch-thumb': thumbSx(theme, flags, palette, geometry.thumbSize, look),
353
+ '& .MuiSwitch-track': trackSx(theme, flags, palette, geometry.height, look),
354
+ };
355
+ };