@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.
- package/package.json +4 -4
- 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.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.tsx +125 -948
- package/src/components/feedback/Modal/Modal.styles.ts +126 -0
- package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
- package/src/components/feedback/Modal/Modal.tsx +37 -242
- 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.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/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/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,291 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Box,
|
|
3
|
+
ButtonBase,
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
FormControlLabel,
|
|
7
|
+
FormLabel,
|
|
8
|
+
Radio,
|
|
9
|
+
RadioGroup as MuiRadioGroup,
|
|
10
|
+
Typography,
|
|
11
|
+
} from '@mui/material';
|
|
12
|
+
import { styled } from '@mui/material/styles';
|
|
13
|
+
import React from 'react';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
buttonRadioSx,
|
|
17
|
+
formLabelSx,
|
|
18
|
+
radioCardSx,
|
|
19
|
+
segmentButtonSx,
|
|
20
|
+
segmentContainerSx,
|
|
21
|
+
} from './RadioGroup.styles';
|
|
22
|
+
import type { SurfaceFlags } from './RadioGroup.styles';
|
|
23
|
+
import type { RadioOption, RadioGroupProps } from './RadioGroup.types';
|
|
24
|
+
|
|
25
|
+
const StyledFormLabel = styled(FormLabel, {
|
|
26
|
+
shouldForwardProp: (prop) => !['glass', 'error'].includes(prop as string),
|
|
27
|
+
})<{ glass?: boolean; error?: boolean }>(({ theme, glass, error }) => ({
|
|
28
|
+
...formLabelSx(theme, glass, error),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const StyledRadioCard = styled(Card, {
|
|
32
|
+
shouldForwardProp: (prop) =>
|
|
33
|
+
!['selected', 'customColor', 'glass', 'gradient', 'glow', 'customSize', 'animated'].includes(
|
|
34
|
+
prop as string,
|
|
35
|
+
),
|
|
36
|
+
})<SurfaceFlags>(({ theme, ...flags }) => ({ ...radioCardSx(theme, flags) }));
|
|
37
|
+
|
|
38
|
+
const StyledButtonRadio = styled(ButtonBase, {
|
|
39
|
+
shouldForwardProp: (prop) =>
|
|
40
|
+
!['selected', 'customColor', 'glass', 'gradient', 'customSize', 'animated'].includes(
|
|
41
|
+
prop as string,
|
|
42
|
+
),
|
|
43
|
+
})<SurfaceFlags>(({ theme, ...flags }) => ({ ...buttonRadioSx(theme, flags) }));
|
|
44
|
+
|
|
45
|
+
const StyledSegmentContainer = styled(Box, {
|
|
46
|
+
shouldForwardProp: (prop) => !['glass', 'customColor'].includes(prop as string),
|
|
47
|
+
})<{ glass?: boolean; customColor?: string }>(({ theme, glass, customColor }) => ({
|
|
48
|
+
...segmentContainerSx(theme, glass, customColor),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const StyledSegmentButton = styled(ButtonBase, {
|
|
52
|
+
shouldForwardProp: (prop) =>
|
|
53
|
+
!['selected', 'customColor', 'customSize', 'animated'].includes(prop as string),
|
|
54
|
+
})<SurfaceFlags>(({ theme, ...flags }) => ({ ...segmentButtonSx(theme, flags) }));
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
export interface VariantProps {
|
|
58
|
+
options: RadioOption[];
|
|
59
|
+
value?: unknown;
|
|
60
|
+
color: NonNullable<RadioGroupProps['color']>;
|
|
61
|
+
size: NonNullable<RadioGroupProps['size']>;
|
|
62
|
+
direction: NonNullable<RadioGroupProps['direction']>;
|
|
63
|
+
showDescriptions: boolean;
|
|
64
|
+
glass: boolean;
|
|
65
|
+
gradient: boolean;
|
|
66
|
+
glow: boolean;
|
|
67
|
+
dataTestId?: string;
|
|
68
|
+
onSelect: (option: RadioOption) => void;
|
|
69
|
+
/** Only the default variant hands its ref and extra props to MUI's group. */
|
|
70
|
+
groupRef?: React.Ref<HTMLDivElement>;
|
|
71
|
+
onChange?: RadioGroupProps['onChange'];
|
|
72
|
+
rest?: Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `${dataTestId}-${suffix}`, or nothing when the caller supplied no id. */
|
|
76
|
+
export const testId = (base: string | undefined, suffix: string) =>
|
|
77
|
+
base ? `${base}-${suffix}` : undefined;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* MUI's Radio has no `danger`; this component's palette calls the error colour
|
|
81
|
+
* that, so the name is translated at the boundary.
|
|
82
|
+
*/
|
|
83
|
+
const muiRadioColor = (color: string) =>
|
|
84
|
+
color === 'danger'
|
|
85
|
+
? 'error'
|
|
86
|
+
: (color as 'primary' | 'secondary' | 'success' | 'warning' | 'error');
|
|
87
|
+
|
|
88
|
+
/** An option's icon beside its label, the run every variant but `cards` uses. */
|
|
89
|
+
const OptionLabel: React.FC<{ option: RadioOption; testIdValue?: string }> = ({
|
|
90
|
+
option,
|
|
91
|
+
testIdValue,
|
|
92
|
+
}) => (
|
|
93
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }} data-testid={testIdValue}>
|
|
94
|
+
{option.icon}
|
|
95
|
+
{option.label}
|
|
96
|
+
</Box>
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
export const DefaultRadios: React.FC<VariantProps> = ({
|
|
100
|
+
options,
|
|
101
|
+
value,
|
|
102
|
+
color,
|
|
103
|
+
direction,
|
|
104
|
+
showDescriptions,
|
|
105
|
+
dataTestId,
|
|
106
|
+
groupRef,
|
|
107
|
+
onChange,
|
|
108
|
+
rest,
|
|
109
|
+
}) => (
|
|
110
|
+
<MuiRadioGroup
|
|
111
|
+
ref={groupRef}
|
|
112
|
+
value={value}
|
|
113
|
+
onChange={onChange}
|
|
114
|
+
{...rest}
|
|
115
|
+
sx={{ flexDirection: direction, gap: 1 }}
|
|
116
|
+
data-testid={testId(dataTestId, 'group')}
|
|
117
|
+
>
|
|
118
|
+
{options.map((option, index) => (
|
|
119
|
+
<FormControlLabel
|
|
120
|
+
key={option.value}
|
|
121
|
+
value={option.value}
|
|
122
|
+
disabled={option.disabled}
|
|
123
|
+
data-testid={testId(dataTestId, `option-${index}`)}
|
|
124
|
+
control={
|
|
125
|
+
<Radio
|
|
126
|
+
color={muiRadioColor(color)}
|
|
127
|
+
data-testid={testId(dataTestId, `radio-${index}`)}
|
|
128
|
+
/>
|
|
129
|
+
}
|
|
130
|
+
label={
|
|
131
|
+
<Box
|
|
132
|
+
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
|
133
|
+
data-testid={testId(dataTestId, `label-${index}`)}
|
|
134
|
+
>
|
|
135
|
+
{option.icon}
|
|
136
|
+
<Box>
|
|
137
|
+
<Typography>{option.label}</Typography>
|
|
138
|
+
{option.description && showDescriptions && (
|
|
139
|
+
<Typography variant="caption" color="text.secondary">
|
|
140
|
+
{option.description}
|
|
141
|
+
</Typography>
|
|
142
|
+
)}
|
|
143
|
+
</Box>
|
|
144
|
+
</Box>
|
|
145
|
+
}
|
|
146
|
+
/>
|
|
147
|
+
))}
|
|
148
|
+
</MuiRadioGroup>
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
export interface CardBodyProps {
|
|
152
|
+
option: RadioOption;
|
|
153
|
+
index: number;
|
|
154
|
+
isSelected: boolean;
|
|
155
|
+
color: string;
|
|
156
|
+
showDescriptions: boolean;
|
|
157
|
+
dataTestId?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A card's contents: the icon, the label, and the optional description under it. */
|
|
161
|
+
export const CardBody: React.FC<CardBodyProps> = ({
|
|
162
|
+
option,
|
|
163
|
+
index,
|
|
164
|
+
isSelected,
|
|
165
|
+
color,
|
|
166
|
+
showDescriptions,
|
|
167
|
+
dataTestId,
|
|
168
|
+
}) => (
|
|
169
|
+
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
|
170
|
+
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
|
171
|
+
{option.icon && (
|
|
172
|
+
<Box sx={{ mt: 0.5, color: isSelected ? `${color}.main` : 'text.secondary' }}>
|
|
173
|
+
{option.icon}
|
|
174
|
+
</Box>
|
|
175
|
+
)}
|
|
176
|
+
<Box sx={{ flex: 1 }}>
|
|
177
|
+
<Typography
|
|
178
|
+
variant="subtitle1"
|
|
179
|
+
fontWeight={600}
|
|
180
|
+
color={isSelected ? `${color}.main` : 'text.primary'}
|
|
181
|
+
data-testid={testId(dataTestId, `label-${index}`)}
|
|
182
|
+
>
|
|
183
|
+
{option.label}
|
|
184
|
+
</Typography>
|
|
185
|
+
{option.description && showDescriptions && (
|
|
186
|
+
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
187
|
+
{option.description}
|
|
188
|
+
</Typography>
|
|
189
|
+
)}
|
|
190
|
+
</Box>
|
|
191
|
+
</Box>
|
|
192
|
+
</CardContent>
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
export { OptionLabel };
|
|
196
|
+
|
|
197
|
+
export const CardRadios: React.FC<VariantProps> = ({
|
|
198
|
+
options, value, color, size, direction, showDescriptions, glass, gradient, glow, dataTestId, onSelect,
|
|
199
|
+
}) => (
|
|
200
|
+
<Box
|
|
201
|
+
sx={{ display: 'flex', flexDirection: direction, gap: 2 }}
|
|
202
|
+
data-testid={testId(dataTestId, 'container')}
|
|
203
|
+
>
|
|
204
|
+
{options.map((option, index) => (
|
|
205
|
+
<StyledRadioCard
|
|
206
|
+
key={option.value}
|
|
207
|
+
selected={value === option.value}
|
|
208
|
+
customColor={color}
|
|
209
|
+
glass={glass}
|
|
210
|
+
gradient={gradient}
|
|
211
|
+
glow={glow}
|
|
212
|
+
customSize={size}
|
|
213
|
+
animated
|
|
214
|
+
data-testid={testId(dataTestId, `card-${index}`)}
|
|
215
|
+
onClick={() => onSelect(option)}
|
|
216
|
+
>
|
|
217
|
+
<CardBody
|
|
218
|
+
option={option}
|
|
219
|
+
index={index}
|
|
220
|
+
isSelected={value === option.value}
|
|
221
|
+
color={color}
|
|
222
|
+
showDescriptions={showDescriptions}
|
|
223
|
+
dataTestId={dataTestId}
|
|
224
|
+
/>
|
|
225
|
+
</StyledRadioCard>
|
|
226
|
+
))}
|
|
227
|
+
</Box>
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
export const ButtonRadios: React.FC<VariantProps> = ({
|
|
231
|
+
options, value, color, size, direction, glass, gradient, dataTestId, onSelect,
|
|
232
|
+
}) => (
|
|
233
|
+
<Box
|
|
234
|
+
sx={{ display: 'flex', flexDirection: direction, gap: 1, flexWrap: 'wrap' }}
|
|
235
|
+
data-testid={testId(dataTestId, 'container')}
|
|
236
|
+
>
|
|
237
|
+
{options.map((option, index) => (
|
|
238
|
+
<StyledButtonRadio
|
|
239
|
+
key={option.value}
|
|
240
|
+
selected={value === option.value}
|
|
241
|
+
customColor={color}
|
|
242
|
+
glass={glass}
|
|
243
|
+
gradient={gradient}
|
|
244
|
+
customSize={size}
|
|
245
|
+
animated
|
|
246
|
+
disabled={option.disabled}
|
|
247
|
+
data-testid={testId(dataTestId, `button-${index}`)}
|
|
248
|
+
onClick={() => onSelect(option)}
|
|
249
|
+
>
|
|
250
|
+
<OptionLabel option={option} testIdValue={testId(dataTestId, `label-${index}`)} />
|
|
251
|
+
</StyledButtonRadio>
|
|
252
|
+
))}
|
|
253
|
+
</Box>
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
export const SegmentRadios: React.FC<VariantProps> = ({
|
|
257
|
+
options, value, color, size, glass, dataTestId, onSelect,
|
|
258
|
+
}) => (
|
|
259
|
+
<StyledSegmentContainer
|
|
260
|
+
glass={glass}
|
|
261
|
+
customColor={color}
|
|
262
|
+
data-testid={testId(dataTestId, 'container')}
|
|
263
|
+
>
|
|
264
|
+
{options.map((option, index) => (
|
|
265
|
+
<StyledSegmentButton
|
|
266
|
+
key={option.value}
|
|
267
|
+
selected={value === option.value}
|
|
268
|
+
customColor={color}
|
|
269
|
+
customSize={size}
|
|
270
|
+
animated
|
|
271
|
+
disabled={option.disabled}
|
|
272
|
+
data-testid={testId(dataTestId, `segment-${index}`)}
|
|
273
|
+
onClick={() => onSelect(option)}
|
|
274
|
+
>
|
|
275
|
+
<OptionLabel option={option} testIdValue={testId(dataTestId, `label-${index}`)} />
|
|
276
|
+
</StyledSegmentButton>
|
|
277
|
+
))}
|
|
278
|
+
</StyledSegmentContainer>
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
/** The group's own label, which can carry the glass treatment independently. */
|
|
282
|
+
export const GroupLabel: React.FC<{
|
|
283
|
+
glass?: boolean;
|
|
284
|
+
error?: boolean;
|
|
285
|
+
dataTestId?: string;
|
|
286
|
+
children: React.ReactNode;
|
|
287
|
+
}> = ({ glass, error, dataTestId, children }) => (
|
|
288
|
+
<StyledFormLabel glass={glass} error={error} data-testid={testId(dataTestId, 'label')}>
|
|
289
|
+
{children}
|
|
290
|
+
</StyledFormLabel>
|
|
291
|
+
);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { keyframes } from '@mui/material';
|
|
2
|
+
|
|
3
|
+
export const glowAnimation = keyframes`
|
|
4
|
+
0% {
|
|
5
|
+
box-shadow: 0 0 5px currentColor;
|
|
6
|
+
}
|
|
7
|
+
50% {
|
|
8
|
+
box-shadow: 0 0 15px currentColor, 0 0 25px currentColor;
|
|
9
|
+
}
|
|
10
|
+
100% {
|
|
11
|
+
box-shadow: 0 0 5px currentColor;
|
|
12
|
+
}
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
export const pulseAnimation = keyframes`
|
|
16
|
+
0% {
|
|
17
|
+
transform: scale(1);
|
|
18
|
+
opacity: 1;
|
|
19
|
+
}
|
|
20
|
+
50% {
|
|
21
|
+
transform: scale(1.5);
|
|
22
|
+
opacity: 0.5;
|
|
23
|
+
}
|
|
24
|
+
100% {
|
|
25
|
+
transform: scale(2);
|
|
26
|
+
opacity: 0;
|
|
27
|
+
}
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
export const gradientShiftAnimation = keyframes`
|
|
31
|
+
0% {
|
|
32
|
+
background-position: 0% 50%;
|
|
33
|
+
}
|
|
34
|
+
50% {
|
|
35
|
+
background-position: 100% 50%;
|
|
36
|
+
}
|
|
37
|
+
100% {
|
|
38
|
+
background-position: 0% 50%;
|
|
39
|
+
}
|
|
40
|
+
`;
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { alpha } from '@mui/material';
|
|
2
|
+
import type { CSSObject, Theme } from '@mui/material';
|
|
3
|
+
|
|
4
|
+
import { glowAnimation, gradientShiftAnimation, pulseAnimation } from './Slider.animations';
|
|
5
|
+
|
|
6
|
+
interface ColorPalette {
|
|
7
|
+
main: string;
|
|
8
|
+
dark?: string;
|
|
9
|
+
light?: string;
|
|
10
|
+
contrastText?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `neutral` is not a MUI palette entry, so it is built from the grey ramp. */
|
|
14
|
+
const neutralPalette = (theme: Theme): ColorPalette => ({
|
|
15
|
+
main: theme.palette.grey[700],
|
|
16
|
+
dark: theme.palette.grey[800],
|
|
17
|
+
light: theme.palette.grey[500],
|
|
18
|
+
contrastText: '#fff',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** `danger` is this component's name for the error palette. */
|
|
22
|
+
const namedPalette = (theme: Theme, color: string): ColorPalette => {
|
|
23
|
+
const colorMap: Record<string, ColorPalette> = {
|
|
24
|
+
primary: theme.palette.primary,
|
|
25
|
+
secondary: theme.palette.secondary,
|
|
26
|
+
success: theme.palette.success,
|
|
27
|
+
warning: theme.palette.warning,
|
|
28
|
+
danger: theme.palette.error,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return colorMap[color] || theme.palette.primary;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const getColorFromTheme = (theme: Theme, color: string): ColorPalette => {
|
|
35
|
+
if (color === 'neutral') {
|
|
36
|
+
return neutralPalette(theme);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const palette = namedPalette(theme, color);
|
|
40
|
+
const { primary } = theme.palette;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
main: palette.main || primary.main,
|
|
44
|
+
dark: palette.dark || palette.main || primary.dark,
|
|
45
|
+
light: palette.light || palette.main || primary.light,
|
|
46
|
+
contrastText: palette.contrastText || '#fff',
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type SizeKey = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
|
51
|
+
|
|
52
|
+
interface Geometry {
|
|
53
|
+
height: number;
|
|
54
|
+
thumbSize: number;
|
|
55
|
+
markHeight: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const SIZES: Record<SizeKey, Geometry> = {
|
|
59
|
+
xs: { height: 4, thumbSize: 16, markHeight: 8 },
|
|
60
|
+
sm: { height: 6, thumbSize: 18, markHeight: 10 },
|
|
61
|
+
md: { height: 8, thumbSize: 20, markHeight: 12 },
|
|
62
|
+
lg: { height: 10, thumbSize: 24, markHeight: 14 },
|
|
63
|
+
xl: { height: 12, thumbSize: 28, markHeight: 16 },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The value bubble and mark labels shrink at the two smallest sizes only. */
|
|
67
|
+
const LABEL_SCALE: Record<string, { fontSize: number; box: number; markFont: string }> = {
|
|
68
|
+
xs: { fontSize: 10, box: 28, markFont: '0.65rem' },
|
|
69
|
+
sm: { fontSize: 11, box: 30, markFont: '0.7rem' },
|
|
70
|
+
};
|
|
71
|
+
const LABEL_DEFAULT = { fontSize: 12, box: 32, markFont: '0.75rem' };
|
|
72
|
+
|
|
73
|
+
export interface SliderFlags {
|
|
74
|
+
customColor?: string;
|
|
75
|
+
customSize?: string;
|
|
76
|
+
glow?: boolean;
|
|
77
|
+
glass?: boolean;
|
|
78
|
+
gradient?: boolean;
|
|
79
|
+
customVariant?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface PartInput {
|
|
83
|
+
theme: Theme;
|
|
84
|
+
flags: SliderFlags;
|
|
85
|
+
palette: ColorPalette;
|
|
86
|
+
geometry: Geometry;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const trackPart = ({ flags, palette, geometry }: PartInput): CSSObject => {
|
|
90
|
+
const { gradient, glow, glass, customVariant } = flags;
|
|
91
|
+
const isGradientVariant = customVariant === 'gradient';
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
border: 'none',
|
|
95
|
+
height: geometry.height,
|
|
96
|
+
borderRadius: geometry.height / 2,
|
|
97
|
+
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
98
|
+
position: 'relative',
|
|
99
|
+
overflow: 'hidden',
|
|
100
|
+
...(gradient && {
|
|
101
|
+
background: isGradientVariant
|
|
102
|
+
? `linear-gradient(90deg,
|
|
103
|
+
${palette.light} 0%,
|
|
104
|
+
${palette.main} 50%,
|
|
105
|
+
${palette.dark} 100%)`
|
|
106
|
+
: `linear-gradient(90deg, ${palette.light}, ${palette.main})`,
|
|
107
|
+
backgroundSize: '200% 100%',
|
|
108
|
+
animation: isGradientVariant ? `${gradientShiftAnimation} 3s ease infinite` : 'none',
|
|
109
|
+
// A sheen that travels the filled portion while the gradient shifts.
|
|
110
|
+
'&::after': {
|
|
111
|
+
content: '""',
|
|
112
|
+
position: 'absolute',
|
|
113
|
+
top: 0,
|
|
114
|
+
left: 0,
|
|
115
|
+
right: 0,
|
|
116
|
+
bottom: 0,
|
|
117
|
+
background: `linear-gradient(90deg, transparent, ${alpha('#fff', 0.2)}, transparent)`,
|
|
118
|
+
animation: `${gradientShiftAnimation} 2s linear infinite`,
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
...(glow && {
|
|
122
|
+
animation: `${glowAnimation} 2s ease-in-out infinite`,
|
|
123
|
+
boxShadow: `0 0 10px ${alpha(palette.main, 0.6)}, inset 0 0 10px ${alpha(palette.main, 0.2)}`,
|
|
124
|
+
}),
|
|
125
|
+
...(glass && {
|
|
126
|
+
backgroundColor: alpha(palette.main, 0.8),
|
|
127
|
+
backdropFilter: 'blur(10px)',
|
|
128
|
+
border: `1px solid ${alpha(palette.light || palette.main, 0.3)}`,
|
|
129
|
+
}),
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const railPart = ({ theme, flags, geometry }: PartInput): CSSObject => ({
|
|
134
|
+
color: alpha(theme.palette.action.disabled, 0.3),
|
|
135
|
+
opacity: 1,
|
|
136
|
+
height: geometry.height,
|
|
137
|
+
borderRadius: geometry.height / 2,
|
|
138
|
+
transition: 'all 0.3s ease',
|
|
139
|
+
...(flags.glass && {
|
|
140
|
+
backgroundColor: alpha(theme.palette.background.paper, 0.1),
|
|
141
|
+
backdropFilter: 'blur(20px)',
|
|
142
|
+
border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
|
|
143
|
+
}),
|
|
144
|
+
...(flags.customVariant === 'gradient' && {
|
|
145
|
+
background: `linear-gradient(90deg,
|
|
146
|
+
${alpha(theme.palette.action.disabled, 0.2)},
|
|
147
|
+
${alpha(theme.palette.action.disabled, 0.3)},
|
|
148
|
+
${alpha(theme.palette.action.disabled, 0.2)})`,
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const thumbPart = ({ theme, flags, palette, geometry }: PartInput): CSSObject => {
|
|
153
|
+
const { gradient, glow, glass } = flags;
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
height: geometry.thumbSize,
|
|
157
|
+
width: geometry.thumbSize,
|
|
158
|
+
backgroundColor: gradient ? palette.main : '#fff',
|
|
159
|
+
border: `2px solid ${palette.main}`,
|
|
160
|
+
boxShadow: `${theme.shadows[2]}, 0 0 0 0 ${alpha(palette.main, 0.2)}`,
|
|
161
|
+
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
162
|
+
position: 'relative',
|
|
163
|
+
// Sits invisible until focus or hover runs the pulse keyframes over it.
|
|
164
|
+
'&::before': {
|
|
165
|
+
content: '""',
|
|
166
|
+
position: 'absolute',
|
|
167
|
+
top: '50%',
|
|
168
|
+
left: '50%',
|
|
169
|
+
transform: 'translate(-50%, -50%)',
|
|
170
|
+
width: '100%',
|
|
171
|
+
height: '100%',
|
|
172
|
+
borderRadius: '50%',
|
|
173
|
+
backgroundColor: palette.main,
|
|
174
|
+
opacity: 0,
|
|
175
|
+
},
|
|
176
|
+
'&:focus, &:hover, &.Mui-active, &.Mui-focusVisible': {
|
|
177
|
+
boxShadow: glow
|
|
178
|
+
? `${theme.shadows[4]}, 0 0 20px ${alpha(palette.main, 0.6)}, 0 0 0 8px ${alpha(palette.main, 0.15)}`
|
|
179
|
+
: `${theme.shadows[4]}, 0 0 0 8px ${alpha(palette.main, 0.15)}`,
|
|
180
|
+
transform: 'scale(1.15)',
|
|
181
|
+
'&::before': { animation: `${pulseAnimation} 0.6s ease-out` },
|
|
182
|
+
},
|
|
183
|
+
'&:active': { transform: 'scale(1.05)' },
|
|
184
|
+
...(glass && {
|
|
185
|
+
backgroundColor: alpha(theme.palette.background.paper, 0.9),
|
|
186
|
+
backdropFilter: 'blur(10px)',
|
|
187
|
+
border: `2px solid ${alpha(palette.main, 0.8)}`,
|
|
188
|
+
}),
|
|
189
|
+
...(gradient && {
|
|
190
|
+
background: `linear-gradient(135deg, ${palette.light}, ${palette.main})`,
|
|
191
|
+
border: 'none',
|
|
192
|
+
color: '#fff',
|
|
193
|
+
}),
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Transparent under a gradient (the gradient itself paints it), else the accent. */
|
|
198
|
+
const bubbleBackground = (palette: ColorPalette, gradient: boolean, glass: boolean) => {
|
|
199
|
+
if (gradient) return 'transparent';
|
|
200
|
+
return glass ? alpha(palette.main, 0.9) : palette.main;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** The bubble's closed and open transforms; only the scale differs between them. */
|
|
204
|
+
const bubbleTransformFor = (gradient: boolean) => {
|
|
205
|
+
const base = gradient ? 'translate(50%, -150%)' : 'translate(50%, -100%) rotate(-45deg)';
|
|
206
|
+
return { hidden: `${base} scale(0)`, shown: `${base} scale(1)` };
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The value bubble. Without `gradient` it is MUI's teardrop — a rotated square
|
|
211
|
+
* with one square corner — so its contents are counter-rotated; the gradient
|
|
212
|
+
* look is a plain rounded box and needs neither rotation.
|
|
213
|
+
*/
|
|
214
|
+
const valueLabelPart = ({ theme, flags, palette }: PartInput): CSSObject => {
|
|
215
|
+
const { gradient, glass, customSize } = flags;
|
|
216
|
+
const scale = LABEL_SCALE[customSize ?? ''] ?? LABEL_DEFAULT;
|
|
217
|
+
const bubbleTransform = bubbleTransformFor(Boolean(gradient));
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
lineHeight: 1.2,
|
|
221
|
+
fontSize: scale.fontSize,
|
|
222
|
+
padding: 0,
|
|
223
|
+
width: scale.box,
|
|
224
|
+
height: scale.box,
|
|
225
|
+
borderRadius: gradient ? '8px' : '50% 50% 50% 0',
|
|
226
|
+
backgroundColor: bubbleBackground(palette, Boolean(gradient), Boolean(glass)),
|
|
227
|
+
background: gradient
|
|
228
|
+
? `linear-gradient(135deg, ${palette.light}, ${palette.main})`
|
|
229
|
+
: 'unset',
|
|
230
|
+
backdropFilter: glass ? 'blur(10px)' : 'none',
|
|
231
|
+
transformOrigin: 'bottom left',
|
|
232
|
+
transform: bubbleTransform.hidden,
|
|
233
|
+
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
234
|
+
boxShadow: `${theme.shadows[2]}, 0 0 10px ${alpha(palette.main, 0.2)}`,
|
|
235
|
+
'&:before': { display: 'none' },
|
|
236
|
+
'&.MuiSlider-valueLabelOpen': { transform: bubbleTransform.shown },
|
|
237
|
+
'& > *': { transform: gradient ? 'none' : 'rotate(45deg)', fontWeight: 600 },
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const markPart = ({ theme, flags, palette, geometry }: PartInput): CSSObject => {
|
|
242
|
+
const { gradient, glow, customVariant } = flags;
|
|
243
|
+
const isMarksVariant = customVariant === 'marks';
|
|
244
|
+
const { markHeight, height } = geometry;
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
backgroundColor: alpha(theme.palette.action.disabled, 0.5),
|
|
248
|
+
height: markHeight,
|
|
249
|
+
width: isMarksVariant ? 3 : 2,
|
|
250
|
+
// Centred on the rail rather than hanging below it.
|
|
251
|
+
marginTop: -(markHeight - height) / 2,
|
|
252
|
+
borderRadius: 1,
|
|
253
|
+
transition: 'all 0.3s ease',
|
|
254
|
+
'&.MuiSlider-markActive': {
|
|
255
|
+
backgroundColor: gradient ? palette.light : palette.main,
|
|
256
|
+
width: isMarksVariant ? 4 : 2,
|
|
257
|
+
height: markHeight + 2,
|
|
258
|
+
marginTop: -(markHeight + 2 - height) / 2,
|
|
259
|
+
...(glow && { boxShadow: `0 0 8px ${alpha(palette.main, 0.5)}` }),
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const markLabelPart = ({ theme, flags, palette }: PartInput): CSSObject => ({
|
|
265
|
+
fontSize: (LABEL_SCALE[flags.customSize ?? ''] ?? LABEL_DEFAULT).markFont,
|
|
266
|
+
color: theme.palette.text.secondary,
|
|
267
|
+
marginTop: theme.spacing(1.5),
|
|
268
|
+
fontWeight: 500,
|
|
269
|
+
transition: 'all 0.3s ease',
|
|
270
|
+
'&.MuiSlider-markLabelActive': {
|
|
271
|
+
color: palette.main,
|
|
272
|
+
fontWeight: 600,
|
|
273
|
+
transform: 'scale(1.05)',
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
export const sliderSx = (theme: Theme, flags: SliderFlags): CSSObject => {
|
|
278
|
+
const palette = getColorFromTheme(theme, flags.customColor ?? 'primary');
|
|
279
|
+
const geometry = SIZES[flags.customSize as SizeKey] ?? SIZES.md;
|
|
280
|
+
const input: PartInput = { theme, flags, palette, geometry };
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
color: palette.main,
|
|
284
|
+
height: geometry.height,
|
|
285
|
+
'& .MuiSlider-track': trackPart(input),
|
|
286
|
+
'& .MuiSlider-rail': railPart(input),
|
|
287
|
+
'& .MuiSlider-thumb': thumbPart(input),
|
|
288
|
+
'& .MuiSlider-valueLabel': valueLabelPart(input),
|
|
289
|
+
'& .MuiSlider-mark': markPart(input),
|
|
290
|
+
'& .MuiSlider-markLabel': markLabelPart(input),
|
|
291
|
+
};
|
|
292
|
+
};
|