@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,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
+ };