@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.
Files changed (66) hide show
  1. package/eslint.config.js +12 -0
  2. package/package.json +5 -4
  3. package/src/components/data-display/AlertDialog/AlertDialog.test.stories.tsx +16 -6
  4. package/src/components/data-display/Banner/Banner.test.stories.tsx +26 -18
  5. package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
  6. package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
  7. package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
  8. package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
  9. package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
  10. package/src/components/data-display/Carousel/Carousel.test.stories.tsx +15 -5
  11. package/src/components/data-display/Carousel/Carousel.tsx +84 -732
  12. package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
  13. package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
  14. package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
  15. package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
  16. package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
  17. package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
  18. package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
  19. package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
  20. package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
  21. package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
  22. package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
  23. package/src/components/data-display/Sheet/Sheet.test.stories.tsx +25 -21
  24. package/src/components/data-display/Sheet/Sheet.tsx +125 -948
  25. package/src/components/feedback/Dialog/Dialog.test.stories.tsx +37 -28
  26. package/src/components/feedback/Modal/Modal.styles.ts +126 -0
  27. package/src/components/feedback/Modal/Modal.test.stories.tsx +5 -5
  28. package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
  29. package/src/components/feedback/Modal/Modal.tsx +37 -242
  30. package/src/components/feedback/StackedModal/StackedModal.test.stories.tsx +9 -11
  31. package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
  32. package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
  33. package/src/components/form/InputOTP/InputOTP.tsx +74 -221
  34. package/src/components/form/Label/Label.parts.tsx +75 -0
  35. package/src/components/form/Label/Label.styles.ts +206 -0
  36. package/src/components/form/Label/Label.test.stories.tsx +20 -14
  37. package/src/components/form/Label/Label.tsx +73 -325
  38. package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
  39. package/src/components/form/Menubar/Menubar.item.tsx +121 -0
  40. package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
  41. package/src/components/form/Menubar/Menubar.styles.ts +110 -0
  42. package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
  43. package/src/components/form/Menubar/Menubar.tsx +64 -347
  44. package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
  45. package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
  46. package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
  47. package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
  48. package/src/components/form/Slider/Slider.animations.ts +40 -0
  49. package/src/components/form/Slider/Slider.styles.ts +292 -0
  50. package/src/components/form/Slider/Slider.tsx +106 -357
  51. package/src/components/form/Switch/Switch.animations.ts +62 -0
  52. package/src/components/form/Switch/Switch.parts.tsx +213 -0
  53. package/src/components/form/Switch/Switch.styles.ts +355 -0
  54. package/src/components/form/Switch/Switch.tsx +87 -566
  55. package/src/components/form/ToggleGroup/ToggleGroup.test.stories.tsx +26 -29
  56. package/src/components/layout/Resizable/Resizable.test.stories.tsx +11 -7
  57. package/src/components/layout/Spacer/Spacer.test.stories.tsx +11 -9
  58. package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
  59. package/src/components/typography/Heading/Heading.styles.ts +101 -0
  60. package/src/components/typography/Heading/Heading.tsx +48 -164
  61. package/src/components/typography/Text/Text.test.stories.tsx +27 -30
  62. package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
  63. package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
  64. package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
  65. package/src/utils/index.ts +1 -0
  66. package/src/utils/withDefaults.ts +20 -0
@@ -0,0 +1,73 @@
1
+ import { keyframes } from '@mui/material';
2
+
3
+ export const pulseAnimation = keyframes`
4
+ 0% {
5
+ box-shadow: 0 0 0 0 currentColor;
6
+ opacity: 1;
7
+ }
8
+ 70% {
9
+ box-shadow: 0 0 0 10px currentColor;
10
+ opacity: 0;
11
+ }
12
+ 100% {
13
+ box-shadow: 0 0 0 0 currentColor;
14
+ opacity: 0;
15
+ }
16
+ `;
17
+
18
+ export const slideInUp = keyframes`
19
+ from {
20
+ opacity: 0;
21
+ transform: translateY(8px);
22
+ }
23
+ to {
24
+ opacity: 1;
25
+ transform: translateY(0);
26
+ }
27
+ `;
28
+
29
+ export const slideInDown = keyframes`
30
+ from {
31
+ opacity: 0;
32
+ transform: translateY(-8px);
33
+ }
34
+ to {
35
+ opacity: 1;
36
+ transform: translateY(0);
37
+ }
38
+ `;
39
+
40
+ export const slideInLeft = keyframes`
41
+ from {
42
+ opacity: 0;
43
+ transform: translateX(8px);
44
+ }
45
+ to {
46
+ opacity: 1;
47
+ transform: translateX(0);
48
+ }
49
+ `;
50
+
51
+ export const slideInRight = keyframes`
52
+ from {
53
+ opacity: 0;
54
+ transform: translateX(-8px);
55
+ }
56
+ to {
57
+ opacity: 1;
58
+ transform: translateX(0);
59
+ }
60
+ `;
61
+
62
+ export const scaleIn = keyframes`
63
+ from {
64
+ opacity: 0;
65
+ transform: scale(0.8);
66
+ }
67
+ to {
68
+ opacity: 1;
69
+ transform: scale(1);
70
+ }
71
+ `;
72
+
73
+ // Arrow component
@@ -0,0 +1,143 @@
1
+ import { Avatar, Box, CircularProgress, Typography } from '@mui/material';
2
+ import { styled } from '@mui/material/styles';
3
+ import React from 'react';
4
+
5
+ import type { HoverCardProps } from './HoverCard.types';
6
+
7
+ const LoadingContainer = styled(Box)(({ theme }) => ({
8
+ display: 'flex',
9
+ alignItems: 'center',
10
+ justifyContent: 'center',
11
+ minHeight: 80,
12
+ color: theme.palette.text.secondary,
13
+ }));
14
+
15
+ /** `${dataTestId}-${suffix}`, or the shared default when the caller gave no id. */
16
+ const testId = (base: string | undefined, suffix: string, fallback: string) =>
17
+ base ? `${base}-${suffix}` : fallback;
18
+
19
+ export interface HoverCardContentProps
20
+ extends Pick<
21
+ HoverCardProps,
22
+ 'variant' | 'title' | 'description' | 'avatar' | 'loading' | 'loadingComponent' | 'children'
23
+ > {
24
+ dataTestId?: string;
25
+ }
26
+
27
+ /** A string avatar is a src; anything else is an element to re-size in place. */
28
+ const CardAvatar: React.FC<{ avatar: HoverCardProps['avatar'] }> = ({ avatar }) => {
29
+ if (!avatar) return null;
30
+
31
+ if (typeof avatar === 'string') {
32
+ return <Avatar src={avatar} sx={{ width: 48, height: 48, mr: 2 }} />;
33
+ }
34
+
35
+ const element = avatar as React.ReactElement<{ sx?: object }>;
36
+ return React.cloneElement(element, {
37
+ sx: { width: 48, height: 48, mr: 2, ...element.props.sx },
38
+ });
39
+ };
40
+
41
+ const Title: React.FC<{
42
+ title: React.ReactNode;
43
+ variant?: string;
44
+ sx?: object;
45
+ dataTestId?: string;
46
+ }> = ({ title, variant, sx, dataTestId }) => (
47
+ <Typography
48
+ variant={variant === 'minimal' ? 'body2' : 'subtitle1'}
49
+ sx={{ fontWeight: 600, ...sx }}
50
+ data-testid={testId(dataTestId, 'title', 'hover-card-title')}
51
+ >
52
+ {title}
53
+ </Typography>
54
+ );
55
+
56
+ const Description: React.FC<{
57
+ description: React.ReactNode;
58
+ sx?: object;
59
+ dataTestId?: string;
60
+ }> = ({ description, sx, dataTestId }) => (
61
+ <Typography
62
+ variant="body2"
63
+ color="text.secondary"
64
+ sx={sx}
65
+ data-testid={testId(dataTestId, 'description', 'hover-card-description')}
66
+ >
67
+ {description}
68
+ </Typography>
69
+ );
70
+
71
+ /** The detailed variant leads with the avatar and sets the text beside it. */
72
+ const DetailedContent: React.FC<HoverCardContentProps> = ({
73
+ title,
74
+ description,
75
+ avatar,
76
+ children,
77
+ dataTestId,
78
+ }) => (
79
+ <>
80
+ {avatar && (
81
+ <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
82
+ <CardAvatar avatar={avatar} />
83
+ <Box>
84
+ {title && (
85
+ <Typography
86
+ variant="h6"
87
+ sx={{ fontWeight: 600, mb: 0.5 }}
88
+ data-testid={testId(dataTestId, 'title', 'hover-card-title')}
89
+ >
90
+ {title}
91
+ </Typography>
92
+ )}
93
+ {description && <Description description={description} dataTestId={dataTestId} />}
94
+ </Box>
95
+ </Box>
96
+ )}
97
+ {!avatar && title && (
98
+ <Typography
99
+ variant="h6"
100
+ sx={{ fontWeight: 600, mb: 1 }}
101
+ data-testid={testId(dataTestId, 'title', 'hover-card-title')}
102
+ >
103
+ {title}
104
+ </Typography>
105
+ )}
106
+ {!avatar && description && (
107
+ <Description description={description} sx={{ mb: 1 }} dataTestId={dataTestId} />
108
+ )}
109
+ {children && <Box>{children}</Box>}
110
+ </>
111
+ );
112
+
113
+ export const HoverCardContent: React.FC<HoverCardContentProps> = (props) => {
114
+ const { variant, title, description, avatar, loading, loadingComponent, children, dataTestId } =
115
+ props;
116
+
117
+ if (loading) {
118
+ return (
119
+ <LoadingContainer>
120
+ {loadingComponent || (
121
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
122
+ <CircularProgress size={20} />
123
+ <Typography variant="body2">Loading...</Typography>
124
+ </Box>
125
+ )}
126
+ </LoadingContainer>
127
+ );
128
+ }
129
+
130
+ if (variant === 'detailed' && (avatar || title || description)) {
131
+ return <DetailedContent {...props} />;
132
+ }
133
+
134
+ return (
135
+ <>
136
+ {title && (
137
+ <Title title={title} variant={variant} sx={{ mb: 0.5 }} dataTestId={dataTestId} />
138
+ )}
139
+ {description && <Description description={description} dataTestId={dataTestId} />}
140
+ {children}
141
+ </>
142
+ );
143
+ };
@@ -0,0 +1,180 @@
1
+ import React from 'react';
2
+
3
+ interface HandlerInput {
4
+ isTouchDevice: boolean;
5
+ touchEnabled: boolean;
6
+ enterDelay: number;
7
+ openCard: (event: React.MouseEvent<HTMLElement> | React.TouchEvent<HTMLElement>) => void;
8
+ closeCard: () => void;
9
+ exitTimeoutRef: React.MutableRefObject<number | undefined>;
10
+ }
11
+
12
+ /**
13
+ * The pointer and touch handlers, over the open/close pair. Touch gets a
14
+ * long-press because there is no hover to respond to; the card's own handlers
15
+ * keep it open while the pointer is travelling onto it.
16
+ */
17
+ const useHoverHandlers = ({
18
+ isTouchDevice,
19
+ touchEnabled,
20
+ enterDelay,
21
+ openCard,
22
+ closeCard,
23
+ exitTimeoutRef,
24
+ }: HandlerInput) => {
25
+ const [touchTimeout, setTouchTimeout] = React.useState<number>();
26
+
27
+ /** A touch device with touch disabled has no hover to respond to. */
28
+ const hoverIgnored = isTouchDevice && !touchEnabled;
29
+
30
+ const cancelTouch = React.useCallback(() => {
31
+ if (touchTimeout) {
32
+ window.clearTimeout(touchTimeout);
33
+ setTouchTimeout(undefined);
34
+ }
35
+ }, [touchTimeout]);
36
+
37
+ React.useEffect(
38
+ () => () => {
39
+ if (touchTimeout) window.clearTimeout(touchTimeout);
40
+ },
41
+ [touchTimeout],
42
+ );
43
+
44
+ const triggerHandlers = {
45
+ onMouseEnter: (event: React.MouseEvent<HTMLElement>) => {
46
+ if (hoverIgnored) return;
47
+ openCard(event);
48
+ },
49
+ onMouseLeave: () => {
50
+ if (hoverIgnored) return;
51
+ closeCard();
52
+ },
53
+ onTouchStart: (event: React.TouchEvent<HTMLElement>) => {
54
+ if (!touchEnabled) return;
55
+ if (touchTimeout) window.clearTimeout(touchTimeout);
56
+ setTouchTimeout(window.setTimeout(() => openCard(event), enterDelay));
57
+ },
58
+ // End and cancel do the same thing: the press did not become a long press.
59
+ onTouchEnd: cancelTouch,
60
+ onTouchCancel: cancelTouch,
61
+ };
62
+
63
+ /** Moving onto the card keeps it open; leaving it starts the exit delay. */
64
+ const cardHandlers = {
65
+ onMouseEnter: () => {
66
+ if (exitTimeoutRef.current) window.clearTimeout(exitTimeoutRef.current);
67
+ },
68
+ onMouseLeave: closeCard,
69
+ };
70
+
71
+ return { triggerHandlers, cardHandlers };
72
+ };
73
+
74
+ /** Escape dismisses the card while it is open. */
75
+ const useEscapeKey = (active: boolean, onEscape: () => void) => {
76
+ React.useEffect(() => {
77
+ if (!active) return;
78
+
79
+ const handleEscape = (event: globalThis.KeyboardEvent) => {
80
+ if (event.key === 'Escape') onEscape();
81
+ };
82
+
83
+ document.addEventListener('keydown', handleEscape);
84
+ return () => document.removeEventListener('keydown', handleEscape);
85
+ }, [active, onEscape]);
86
+ };
87
+
88
+ interface HoverCardTimingInput {
89
+ disabled: boolean;
90
+ touchEnabled: boolean;
91
+ enterDelay: number;
92
+ exitDelay: number;
93
+ onOpen?: () => void;
94
+ onClose?: () => void;
95
+ }
96
+
97
+ /**
98
+ * When the card is open, and the delays that decide it.
99
+ *
100
+ * Three timers are in play: the enter delay before opening, the exit delay
101
+ * before closing (so the pointer can travel from the trigger onto the card
102
+ * without dismissing it), and the long-press timer on touch. Each cancels the
103
+ * others' pending work, which is why they live together.
104
+ */
105
+ export const useHoverCard = ({
106
+ disabled,
107
+ touchEnabled,
108
+ enterDelay,
109
+ exitDelay,
110
+ onOpen,
111
+ onClose,
112
+ }: HoverCardTimingInput) => {
113
+ const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
114
+ const [isOpen, setIsOpen] = React.useState(false);
115
+ const [isTouchDevice, setIsTouchDevice] = React.useState(false);
116
+ const enterTimeoutRef = React.useRef<number | undefined>(undefined);
117
+ const exitTimeoutRef = React.useRef<number | undefined>(undefined);
118
+
119
+ // Probed in an effect, not during render, so SSR and hydration stay intact.
120
+ React.useEffect(() => {
121
+ setIsTouchDevice('ontouchstart' in window);
122
+ }, []);
123
+
124
+ const openCard = React.useCallback(
125
+ (event: React.MouseEvent<HTMLElement> | React.TouchEvent<HTMLElement>) => {
126
+ if (disabled) return;
127
+
128
+ if (exitTimeoutRef.current) {
129
+ window.clearTimeout(exitTimeoutRef.current);
130
+ }
131
+
132
+ const target = event.currentTarget;
133
+ enterTimeoutRef.current = window.setTimeout(() => {
134
+ setAnchorEl(target);
135
+ setIsOpen(true);
136
+ onOpen?.();
137
+ }, enterDelay);
138
+ },
139
+ [disabled, enterDelay, onOpen],
140
+ );
141
+
142
+ const closeCard = React.useCallback(() => {
143
+ if (enterTimeoutRef.current) {
144
+ window.clearTimeout(enterTimeoutRef.current);
145
+ }
146
+
147
+ exitTimeoutRef.current = window.setTimeout(() => {
148
+ setIsOpen(false);
149
+ setAnchorEl(null);
150
+ onClose?.();
151
+ }, exitDelay);
152
+ }, [exitDelay, onClose]);
153
+
154
+ const handleClose = React.useCallback(() => {
155
+ setIsOpen(false);
156
+ setAnchorEl(null);
157
+ onClose?.();
158
+ }, [onClose]);
159
+
160
+ const { triggerHandlers, cardHandlers } = useHoverHandlers({
161
+ isTouchDevice,
162
+ touchEnabled,
163
+ enterDelay,
164
+ openCard,
165
+ closeCard,
166
+ exitTimeoutRef,
167
+ });
168
+
169
+ useEscapeKey(isOpen, handleClose);
170
+
171
+ React.useEffect(
172
+ () => () => {
173
+ if (enterTimeoutRef.current) window.clearTimeout(enterTimeoutRef.current);
174
+ if (exitTimeoutRef.current) window.clearTimeout(exitTimeoutRef.current);
175
+ },
176
+ [],
177
+ );
178
+
179
+ return { anchorEl, isOpen, handleClose, triggerHandlers, cardHandlers };
180
+ };
@@ -0,0 +1,178 @@
1
+ import { alpha } from '@mui/material';
2
+ import type { CSSObject, Theme } from '@mui/material/styles';
3
+
4
+ import {
5
+ pulseAnimation,
6
+ scaleIn,
7
+ slideInDown,
8
+ slideInLeft,
9
+ slideInRight,
10
+ slideInUp,
11
+ } from './HoverCard.animations';
12
+ import type { HoverCardAnimation, HoverCardPlacement } from './HoverCard.types';
13
+
14
+ /** The edge the card sits on, ignoring the `-start`/`-end` alignment suffix. */
15
+ type Side = 'top' | 'bottom' | 'left' | 'right';
16
+
17
+ export const sideOf = (placement: HoverCardPlacement): Side => {
18
+ if (placement.startsWith('top')) return 'top';
19
+ if (placement.startsWith('left')) return 'left';
20
+ if (placement.startsWith('right')) return 'right';
21
+ return 'bottom';
22
+ };
23
+
24
+ const ARROW_SIZE = 8;
25
+
26
+ /**
27
+ * The arrow is a CSS triangle: two transparent borders on the cross axis and one
28
+ * filled border pointing back at the anchor. Every placement is that same shape,
29
+ * so the table holds only which edge is filled and where the triangle sits.
30
+ */
31
+ const ARROWS: Record<Side, (color: string) => CSSObject> = {
32
+ top: (color) => ({
33
+ bottom: -ARROW_SIZE,
34
+ left: '50%',
35
+ transform: 'translateX(-50%)',
36
+ borderLeft: `${ARROW_SIZE}px solid transparent`,
37
+ borderRight: `${ARROW_SIZE}px solid transparent`,
38
+ borderTop: `${ARROW_SIZE}px solid ${color}`,
39
+ }),
40
+ bottom: (color) => ({
41
+ top: -ARROW_SIZE,
42
+ left: '50%',
43
+ transform: 'translateX(-50%)',
44
+ borderLeft: `${ARROW_SIZE}px solid transparent`,
45
+ borderRight: `${ARROW_SIZE}px solid transparent`,
46
+ borderBottom: `${ARROW_SIZE}px solid ${color}`,
47
+ }),
48
+ left: (color) => ({
49
+ right: -ARROW_SIZE,
50
+ top: '50%',
51
+ transform: 'translateY(-50%)',
52
+ borderTop: `${ARROW_SIZE}px solid transparent`,
53
+ borderBottom: `${ARROW_SIZE}px solid transparent`,
54
+ borderLeft: `${ARROW_SIZE}px solid ${color}`,
55
+ }),
56
+ right: (color) => ({
57
+ left: -ARROW_SIZE,
58
+ top: '50%',
59
+ transform: 'translateY(-50%)',
60
+ borderTop: `${ARROW_SIZE}px solid transparent`,
61
+ borderBottom: `${ARROW_SIZE}px solid transparent`,
62
+ borderRight: `${ARROW_SIZE}px solid ${color}`,
63
+ }),
64
+ };
65
+
66
+ export const arrowSx = (theme: Theme, placement: HoverCardPlacement): CSSObject => ({
67
+ position: 'absolute',
68
+ width: 0,
69
+ height: 0,
70
+ zIndex: 1,
71
+ ...ARROWS[sideOf(placement)](theme.palette.background.paper),
72
+ });
73
+
74
+ type Origin = { vertical: 'top' | 'center' | 'bottom'; horizontal: 'left' | 'center' | 'right' };
75
+
76
+ /** Where the popover attaches to the anchor, per side. */
77
+ const ANCHOR_ORIGINS: Record<Side, Origin> = {
78
+ top: { vertical: 'top', horizontal: 'center' },
79
+ bottom: { vertical: 'bottom', horizontal: 'center' },
80
+ left: { vertical: 'center', horizontal: 'left' },
81
+ right: { vertical: 'center', horizontal: 'right' },
82
+ };
83
+
84
+ /** The card's own corner meets the anchor's, so its origin is the opposite one. */
85
+ const flip = (origin: Origin): Origin => ({
86
+ vertical: origin.vertical === 'top' ? 'bottom' : origin.vertical === 'bottom' ? 'top' : 'center',
87
+ horizontal:
88
+ origin.horizontal === 'left' ? 'right' : origin.horizontal === 'right' ? 'left' : 'center',
89
+ });
90
+
91
+ export const getAnchorOrigin = (placement: HoverCardPlacement): Origin =>
92
+ ANCHOR_ORIGINS[sideOf(placement)];
93
+
94
+ export const getTransformOrigin = (placement: HoverCardPlacement): Origin =>
95
+ flip(ANCHOR_ORIGINS[sideOf(placement)]);
96
+
97
+ const ANIMATIONS: Record<string, CSSObject> = {
98
+ // `fade` shares slideInUp deliberately: the keyframes fade in while rising.
99
+ fade: { animation: `${slideInUp} 0.2s ease-out` },
100
+ scale: { transformOrigin: 'center', animation: `${scaleIn} 0.2s cubic-bezier(0.4, 0, 0.2, 1)` },
101
+ 'slide-up': { animation: `${slideInUp} 0.3s cubic-bezier(0.4, 0, 0.2, 1)` },
102
+ 'slide-down': { animation: `${slideInDown} 0.3s cubic-bezier(0.4, 0, 0.2, 1)` },
103
+ 'slide-left': { animation: `${slideInLeft} 0.3s cubic-bezier(0.4, 0, 0.2, 1)` },
104
+ 'slide-right': { animation: `${slideInRight} 0.3s cubic-bezier(0.4, 0, 0.2, 1)` },
105
+ };
106
+
107
+ const VARIANTS: Record<string, (theme: Theme) => CSSObject> = {
108
+ default: (theme) => ({
109
+ backgroundColor: theme.palette.background.paper,
110
+ border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
111
+ boxShadow: theme.shadows[8],
112
+ }),
113
+ glass: (theme) => ({
114
+ backgroundColor: alpha(theme.palette.background.paper, 0.85),
115
+ backdropFilter: 'blur(20px)',
116
+ WebkitBackdropFilter: 'blur(20px)',
117
+ border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
118
+ boxShadow: `0 8px 32px ${alpha(theme.palette.common.black, 0.12)}`,
119
+ }),
120
+ detailed: (theme) => ({
121
+ backgroundColor: theme.palette.background.paper,
122
+ border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
123
+ boxShadow: theme.shadows[12],
124
+ }),
125
+ minimal: (theme) => ({
126
+ backgroundColor: theme.palette.background.paper,
127
+ border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
128
+ boxShadow: theme.shadows[4],
129
+ }),
130
+ };
131
+
132
+ const glowStyles = (theme: Theme): CSSObject => ({
133
+ boxShadow: `0 0 20px 5px ${alpha(theme.palette.primary.main, 0.3)} !important`,
134
+ filter: 'brightness(1.05)',
135
+ });
136
+
137
+ const pulseStyles = (theme: Theme): CSSObject => ({
138
+ position: 'relative',
139
+ '&::after': {
140
+ content: '""',
141
+ position: 'absolute',
142
+ top: 0,
143
+ left: 0,
144
+ right: 0,
145
+ bottom: 0,
146
+ borderRadius: 'inherit',
147
+ backgroundColor: theme.palette.primary.main,
148
+ opacity: 0.1,
149
+ animation: `${pulseAnimation} 2s infinite`,
150
+ pointerEvents: 'none',
151
+ zIndex: -1,
152
+ },
153
+ });
154
+
155
+ export interface CardStyleFlags {
156
+ customVariant?: string;
157
+ glow?: boolean;
158
+ pulse?: boolean;
159
+ animation?: HoverCardAnimation;
160
+ }
161
+
162
+ export const cardSx = (theme: Theme, flags: CardStyleFlags): CSSObject => {
163
+ const { customVariant, glow, pulse, animation } = flags;
164
+
165
+ return {
166
+ borderRadius: theme.spacing(1.5),
167
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
168
+ position: 'relative',
169
+ overflow: 'visible',
170
+ minWidth: 200,
171
+ maxWidth: 400,
172
+ ...(animation ? (ANIMATIONS[animation] ?? {}) : {}),
173
+ ...(customVariant ? (VARIANTS[customVariant]?.(theme) ?? {}) : {}),
174
+ // The old "both" arm was exactly these two spread together.
175
+ ...(glow && glowStyles(theme)),
176
+ ...(pulse && pulseStyles(theme)),
177
+ };
178
+ };