@12-apps/ui 1.4.0 → 1.5.0
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.
- package/eslint.config.js +12 -0
- package/package.json +5 -4
- package/src/components/data-display/AlertDialog/AlertDialog.test.stories.tsx +16 -6
- package/src/components/data-display/Banner/Banner.test.stories.tsx +26 -18
- package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
- package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
- package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
- package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
- package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
- package/src/components/data-display/Carousel/Carousel.test.stories.tsx +15 -5
- package/src/components/data-display/Carousel/Carousel.tsx +84 -732
- package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
- package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
- package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
- package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
- package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
- package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
- package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
- package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
- package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
- package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
- package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
- package/src/components/data-display/Sheet/Sheet.test.stories.tsx +25 -21
- package/src/components/data-display/Sheet/Sheet.tsx +125 -948
- package/src/components/feedback/Dialog/Dialog.test.stories.tsx +37 -28
- package/src/components/feedback/Modal/Modal.styles.ts +126 -0
- package/src/components/feedback/Modal/Modal.test.stories.tsx +5 -5
- package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
- package/src/components/feedback/Modal/Modal.tsx +37 -242
- package/src/components/feedback/StackedModal/StackedModal.test.stories.tsx +9 -11
- package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
- package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
- package/src/components/form/InputOTP/InputOTP.tsx +74 -221
- package/src/components/form/Label/Label.parts.tsx +75 -0
- package/src/components/form/Label/Label.styles.ts +206 -0
- package/src/components/form/Label/Label.test.stories.tsx +20 -14
- package/src/components/form/Label/Label.tsx +73 -325
- package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
- package/src/components/form/Menubar/Menubar.item.tsx +121 -0
- package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
- package/src/components/form/Menubar/Menubar.styles.ts +110 -0
- package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
- package/src/components/form/Menubar/Menubar.tsx +64 -347
- package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
- package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
- package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
- package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
- package/src/components/form/Slider/Slider.animations.ts +40 -0
- package/src/components/form/Slider/Slider.styles.ts +292 -0
- package/src/components/form/Slider/Slider.tsx +106 -357
- package/src/components/form/Switch/Switch.animations.ts +62 -0
- package/src/components/form/Switch/Switch.parts.tsx +213 -0
- package/src/components/form/Switch/Switch.styles.ts +355 -0
- package/src/components/form/Switch/Switch.tsx +87 -566
- package/src/components/form/ToggleGroup/ToggleGroup.test.stories.tsx +26 -29
- package/src/components/layout/Resizable/Resizable.test.stories.tsx +11 -7
- package/src/components/layout/Spacer/Spacer.test.stories.tsx +11 -9
- package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
- package/src/components/typography/Heading/Heading.styles.ts +101 -0
- package/src/components/typography/Heading/Heading.tsx +48 -164
- package/src/components/typography/Text/Text.test.stories.tsx +27 -30
- package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
- package/src/utils/index.ts +1 -0
- 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
|
+
};
|