@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,126 @@
1
+ import { alpha, keyframes } from '@mui/material';
2
+ import type { CSSObject, Theme } from '@mui/material';
3
+
4
+ import type { ModalProps } from './Modal.types';
5
+
6
+ type Variant = NonNullable<ModalProps['variant']>;
7
+ type Size = NonNullable<ModalProps['size']>;
8
+ type Radius = NonNullable<ModalProps['borderRadius']>;
9
+
10
+ const pulseAnimation = keyframes`
11
+ 0% {
12
+ box-shadow: 0 0 0 0 currentColor;
13
+ opacity: 1;
14
+ }
15
+ 70% {
16
+ box-shadow: 0 0 0 20px currentColor;
17
+ opacity: 0;
18
+ }
19
+ 100% {
20
+ box-shadow: 0 0 0 0 currentColor;
21
+ opacity: 0;
22
+ }
23
+ `;
24
+
25
+ const RADII: Record<Radius, (theme: Theme) => number | string> = {
26
+ none: () => 0,
27
+ sm: (theme) => theme.spacing(0.5),
28
+ md: (theme) => theme.spacing(1),
29
+ lg: (theme) => theme.spacing(2),
30
+ xl: (theme) => theme.spacing(3),
31
+ };
32
+
33
+ /** Every preset is capped at 90vw so the panel never overflows a small screen. */
34
+ const WIDTHS: Record<Size, number> = { xs: 320, sm: 480, md: 640, lg: 800, xl: 960 };
35
+
36
+ /**
37
+ * Where the panel sits. Top and bottom are the same layout anchored to opposite
38
+ * edges; centre is the only one that offsets on both axes.
39
+ */
40
+ const PLACEMENTS: Record<string, CSSObject> = {
41
+ top: { top: '10%', left: '50%', transform: 'translateX(-50%)', maxHeight: '80vh' },
42
+ bottom: { bottom: '10%', left: '50%', transform: 'translateX(-50%)', maxHeight: '80vh' },
43
+ center: {
44
+ top: '50%',
45
+ left: '50%',
46
+ transform: 'translate(-50%, -50%)',
47
+ maxHeight: '90vh',
48
+ },
49
+ };
50
+
51
+ interface ModalStyleFlags {
52
+ variant: Variant;
53
+ size: Size;
54
+ borderRadius: Radius;
55
+ glass: boolean;
56
+ gradient: boolean;
57
+ glow: boolean;
58
+ pulse: boolean;
59
+ }
60
+
61
+ /** The ring that expands out of the panel while `pulse` is set. */
62
+ const pulseRing = (theme: Theme): CSSObject => ({
63
+ content: '""',
64
+ position: 'absolute' as const,
65
+ top: 0,
66
+ left: 0,
67
+ right: 0,
68
+ bottom: 0,
69
+ borderRadius: 'inherit',
70
+ backgroundColor: theme.palette.primary.main,
71
+ opacity: 0.3,
72
+ animation: `${pulseAnimation} 2s infinite`,
73
+ pointerEvents: 'none' as const,
74
+ zIndex: -1,
75
+ });
76
+
77
+ /** The surface: its fill, its blur and the shadow that lifts it off the backdrop. */
78
+ const surface = (theme: Theme, flags: ModalStyleFlags): CSSObject => {
79
+ const { variant, glass, gradient, glow } = flags;
80
+ // The glass variant is the glass flag by another name, so either turns it on.
81
+ const isGlass = glass || variant === 'glass';
82
+
83
+ const boxShadow = glow
84
+ ? `0 0 40px ${alpha(theme.palette.primary.main, 0.3)}`
85
+ : isGlass
86
+ ? `0 8px 32px ${alpha(theme.palette.common.black, 0.1)}`
87
+ : theme.shadows[8];
88
+
89
+ return {
90
+ backgroundColor: isGlass
91
+ ? alpha(theme.palette.background.paper, 0.1)
92
+ : theme.palette.background.paper,
93
+ backdropFilter: isGlass ? 'blur(20px)' : gradient ? 'blur(10px)' : 'none',
94
+ border: isGlass ? `1px solid ${alpha(theme.palette.primary.main, 0.2)}` : 'none',
95
+ boxShadow,
96
+ ...(gradient && {
97
+ background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.1)}, ${alpha(theme.palette.secondary.main, 0.1)})`,
98
+ }),
99
+ };
100
+ };
101
+
102
+ export const panelSx = (theme: Theme, flags: ModalStyleFlags): CSSObject => {
103
+ const { variant, size, borderRadius, pulse } = flags;
104
+
105
+ return {
106
+ // A pulsing panel positions its own ring, so it becomes the containing block.
107
+ position: pulse ? ('relative' as const) : ('absolute' as const),
108
+ width: WIDTHS[size] ?? WIDTHS.md,
109
+ maxWidth: '90vw',
110
+ borderRadius: (RADII[borderRadius] ?? RADII.lg)(theme),
111
+ outline: 0,
112
+ overflowY: 'auto',
113
+ transition: theme.transitions.create(
114
+ ['box-shadow', 'background-color', 'backdrop-filter', 'transform'],
115
+ { duration: theme.transitions.duration.standard },
116
+ ),
117
+ ...(PLACEMENTS[variant] ?? PLACEMENTS.center),
118
+ ...surface(theme, flags),
119
+ ...(pulse && { '&::after': pulseRing(theme) }),
120
+ };
121
+ };
122
+
123
+ export const backdropSx = (theme: Theme, isGlass: boolean): CSSObject => ({
124
+ backgroundColor: alpha(theme.palette.common.black, isGlass ? 0.2 : 0.5),
125
+ backdropFilter: isGlass ? 'blur(8px)' : 'none',
126
+ });
@@ -0,0 +1,40 @@
1
+ import { Fade, Slide } from '@mui/material';
2
+ import React from 'react';
3
+
4
+ export interface TransitionProps {
5
+ children?: React.ReactElement;
6
+ in?: boolean;
7
+ timeout?: number;
8
+ }
9
+
10
+ /**
11
+ * A slide from one edge, as its own component type.
12
+ *
13
+ * These are built once at module scope rather than per render: a component
14
+ * created inside a render is a new type on every pass, so React unmounts the
15
+ * modal's whole subtree and remounts it — which also restarts the transition it
16
+ * was in the middle of.
17
+ */
18
+ const slideFrom = (direction: 'up' | 'down', displayName: string) => {
19
+ const Transition = React.forwardRef<HTMLElement, TransitionProps>(({ children, ...rest }, ref) =>
20
+ children ? (
21
+ <Slide direction={direction} ref={ref} {...rest}>
22
+ {children}
23
+ </Slide>
24
+ ) : null,
25
+ );
26
+ Transition.displayName = displayName;
27
+ return Transition;
28
+ };
29
+
30
+ const SlideDown = slideFrom('down', 'SlideDown');
31
+ const SlideUp = slideFrom('up', 'SlideUp');
32
+
33
+ /** A top modal drops in, a bottom one rises; the rest fade. */
34
+ export const TRANSITIONS: Record<string, React.ElementType> = {
35
+ top: SlideDown,
36
+ bottom: SlideUp,
37
+ };
38
+
39
+ export const transitionFor = (variant: string): React.ElementType =>
40
+ TRANSITIONS[variant] ?? Fade;
@@ -1,247 +1,45 @@
1
- import {
2
- alpha,
3
- Backdrop,
4
- Box,
5
- Fade,
6
- keyframes,
7
- Modal as MuiModal,
8
- Slide,
9
- useTheme,
10
- } from '@mui/material';
1
+ import { Backdrop, Box, Modal as MuiModal, useTheme } from '@mui/material';
11
2
  import React from 'react';
12
3
 
13
- import type { ModalContentProps,ModalProps } from './Modal.types';
4
+ import { withDefaults } from '../../../utils/withDefaults';
5
+
6
+ import { backdropSx, panelSx } from './Modal.styles';
7
+ import { transitionFor } from './Modal.transitions';
8
+ import type { ModalContentProps, ModalProps } from './Modal.types';
9
+
10
+ const DEFAULTS = {
11
+ variant: 'center',
12
+ size: 'md',
13
+ backdrop: true,
14
+ persistent: false,
15
+ glass: false,
16
+ gradient: false,
17
+ glow: false,
18
+ pulse: false,
19
+ borderRadius: 'lg',
20
+ } satisfies Partial<ModalProps>;
21
+
22
+ type ResolvedProps = ModalProps & Required<Pick<ModalProps, keyof typeof DEFAULTS>>;
23
+
24
+ export const Modal: React.FC<ModalProps> = (props) => {
25
+ const {
26
+ children, variant, size, backdrop, persistent, glass, gradient, glow, pulse,
27
+ borderRadius, onClose, open, dataTestId,
28
+ ...rest
29
+ } = withDefaults(props, DEFAULTS) as ResolvedProps;
14
30
 
15
- // Define transition component props interface
16
- interface TransitionProps {
17
- children?: React.ReactElement;
18
- in?: boolean;
19
- timeout?: number;
20
- }
21
-
22
- // Define pulse animation
23
- const pulseAnimation = keyframes`
24
- 0% {
25
- box-shadow: 0 0 0 0 currentColor;
26
- opacity: 1;
27
- }
28
- 70% {
29
- box-shadow: 0 0 0 20px currentColor;
30
- opacity: 0;
31
- }
32
- 100% {
33
- box-shadow: 0 0 0 0 currentColor;
34
- opacity: 0;
35
- }
36
- `;
37
-
38
- export const Modal: React.FC<ModalProps> = ({
39
- children,
40
- variant = 'center',
41
- size = 'md',
42
- backdrop = true,
43
- persistent = false,
44
- glass = false,
45
- gradient = false,
46
- glow = false,
47
- pulse = false,
48
- borderRadius = 'lg',
49
- onClose,
50
- open,
51
- dataTestId,
52
- ...props
53
- }) => {
54
31
  const theme = useTheme();
32
+ const isGlass = glass || variant === 'glass';
55
33
 
56
- const getBorderRadius = () => {
57
- switch (borderRadius) {
58
- case 'none':
59
- return 0;
60
- case 'sm':
61
- return theme.spacing(0.5);
62
- case 'md':
63
- return theme.spacing(1);
64
- case 'lg':
65
- return theme.spacing(2);
66
- case 'xl':
67
- return theme.spacing(3);
68
- default:
69
- return theme.spacing(2);
70
- }
71
- };
72
-
73
- const getSize = () => {
74
- switch (size) {
75
- case 'xs':
76
- return { width: 320, maxWidth: '90vw' };
77
- case 'sm':
78
- return { width: 480, maxWidth: '90vw' };
79
- case 'md':
80
- return { width: 640, maxWidth: '90vw' };
81
- case 'lg':
82
- return { width: 800, maxWidth: '90vw' };
83
- case 'xl':
84
- return { width: 960, maxWidth: '90vw' };
85
- default:
86
- return { width: 640, maxWidth: '90vw' };
87
- }
88
- };
89
-
90
- const getPositionStyles = () => {
91
- const sizeStyles = getSize();
92
-
93
- const baseStyles = {
94
- position: 'absolute' as const,
95
- ...sizeStyles,
96
- borderRadius: getBorderRadius(),
97
- outline: 0,
98
- transition: theme.transitions.create(
99
- ['box-shadow', 'background-color', 'backdrop-filter', 'transform'],
100
- {
101
- duration: theme.transitions.duration.standard,
102
- },
103
- ),
104
- };
105
-
106
- switch (variant) {
107
- case 'top':
108
- return {
109
- ...baseStyles,
110
- top: '10%',
111
- left: '50%',
112
- transform: 'translateX(-50%)',
113
- maxHeight: '80vh',
114
- overflowY: 'auto',
115
- };
116
-
117
- case 'bottom':
118
- return {
119
- ...baseStyles,
120
- bottom: '10%',
121
- left: '50%',
122
- transform: 'translateX(-50%)',
123
- maxHeight: '80vh',
124
- overflowY: 'auto',
125
- };
126
-
127
- case 'glass':
128
- case 'center':
129
- default:
130
- return {
131
- ...baseStyles,
132
- top: '50%',
133
- left: '50%',
134
- transform: 'translate(-50%, -50%)',
135
- maxHeight: '90vh',
136
- overflowY: 'auto',
137
- };
138
- }
139
- };
140
-
141
- const getVariantStyles = () => {
142
- const positionStyles = getPositionStyles();
143
-
144
- const baseStyles = {
145
- ...positionStyles,
146
- position: pulse ? ('relative' as const) : positionStyles.position,
147
- };
148
-
149
- if (pulse) {
150
- Object.assign(baseStyles, {
151
- '&::after': {
152
- content: '""',
153
- position: 'absolute' as const,
154
- top: 0,
155
- left: 0,
156
- right: 0,
157
- bottom: 0,
158
- borderRadius: 'inherit',
159
- backgroundColor: theme.palette.primary.main,
160
- opacity: 0.3,
161
- animation: `${pulseAnimation} 2s infinite`,
162
- pointerEvents: 'none' as const,
163
- zIndex: -1,
164
- },
165
- });
166
- }
167
-
168
- switch (variant) {
169
- case 'glass':
170
- return {
171
- ...baseStyles,
172
- backgroundColor: alpha(theme.palette.background.paper, 0.1),
173
- backdropFilter: 'blur(20px)',
174
- border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`,
175
- boxShadow: glow
176
- ? `0 0 40px ${alpha(theme.palette.primary.main, 0.3)}`
177
- : `0 8px 32px ${alpha(theme.palette.common.black, 0.1)}`,
178
- };
179
-
180
- default: {
181
- const styles = {
182
- ...baseStyles,
183
- backgroundColor: glass
184
- ? alpha(theme.palette.background.paper, 0.1)
185
- : theme.palette.background.paper,
186
- backdropFilter: glass ? 'blur(20px)' : gradient ? 'blur(10px)' : 'none',
187
- border: glass ? `1px solid ${alpha(theme.palette.primary.main, 0.2)}` : 'none',
188
- boxShadow: glow
189
- ? `0 0 40px ${alpha(theme.palette.primary.main, 0.3)}`
190
- : glass
191
- ? `0 8px 32px ${alpha(theme.palette.common.black, 0.1)}`
192
- : theme.shadows[8],
193
- };
194
-
195
- if (gradient) {
196
- Object.assign(styles, {
197
- background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.1)}, ${alpha(theme.palette.secondary.main, 0.1)})`,
198
- });
199
- }
200
-
201
- return styles;
202
- }
203
- }
204
- };
205
-
206
- const handleClose = (event: object, reason: 'backdropClick' | 'escapeKeyDown') => {
34
+ /** A persistent modal ignores the two dismissals it did not ask for. */
35
+ const handleClose = (_event: object, reason: 'backdropClick' | 'escapeKeyDown') => {
207
36
  if (persistent && (reason === 'backdropClick' || reason === 'escapeKeyDown')) {
208
37
  return;
209
38
  }
210
39
  onClose?.();
211
40
  };
212
41
 
213
- const getTransitionComponent = () => {
214
- switch (variant) {
215
- case 'top': {
216
- const SlideDown = React.forwardRef<HTMLElement, TransitionProps>((props, ref) => {
217
- const { children, ...rest } = props;
218
- return children ? (
219
- <Slide direction="down" ref={ref} {...rest}>
220
- {children}
221
- </Slide>
222
- ) : null;
223
- });
224
- SlideDown.displayName = 'SlideDown';
225
- return SlideDown;
226
- }
227
- case 'bottom': {
228
- const SlideUp = React.forwardRef<HTMLElement, TransitionProps>((props, ref) => {
229
- const { children, ...rest } = props;
230
- return children ? (
231
- <Slide direction="up" ref={ref} {...rest}>
232
- {children}
233
- </Slide>
234
- ) : null;
235
- });
236
- SlideUp.displayName = 'SlideUp';
237
- return SlideUp;
238
- }
239
- default:
240
- return Fade;
241
- }
242
- };
243
-
244
- const TransitionComponent = getTransitionComponent();
42
+ const TransitionComponent = transitionFor(variant);
245
43
 
246
44
  return (
247
45
  <MuiModal
@@ -251,20 +49,17 @@ export const Modal: React.FC<ModalProps> = ({
251
49
  BackdropComponent={backdrop ? Backdrop : undefined}
252
50
  BackdropProps={{
253
51
  timeout: 500,
254
- sx: {
255
- backgroundColor:
256
- glass || variant === 'glass'
257
- ? alpha(theme.palette.common.black, 0.2)
258
- : alpha(theme.palette.common.black, 0.5),
259
- backdropFilter: glass || variant === 'glass' ? 'blur(8px)' : 'none',
260
- },
52
+ sx: backdropSx(theme, isGlass),
261
53
  // @ts-expect-error - data-testid is not part of BackdropProps type but is valid HTML
262
54
  'data-testid': dataTestId ? `${dataTestId}-backdrop` : 'modal-backdrop',
263
55
  }}
264
- {...props}
56
+ {...rest}
265
57
  >
266
58
  <TransitionComponent in={open} timeout={500}>
267
- <Box sx={getVariantStyles()} data-testid={dataTestId || 'modal'}>
59
+ <Box
60
+ sx={panelSx(theme, { variant, size, borderRadius, glass, gradient, glow, pulse })}
61
+ data-testid={dataTestId || 'modal'}
62
+ >
268
63
  {children}
269
64
  </Box>
270
65
  </TransitionComponent>
@@ -0,0 +1,104 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+
4
+ /**
5
+ * What each variant accepts. `masked` constrains nothing — it only changes what
6
+ * is drawn — so it has no pattern here.
7
+ */
8
+ const PATTERNS: Record<string, RegExp> = {
9
+ numeric: /^\d*$/,
10
+ alphanumeric: /^[a-zA-Z0-9]*$/,
11
+ };
12
+
13
+ /** Whether `text` is something this variant will take. */
14
+ const isAllowed = (variant: string, text: string) => (PATTERNS[variant] ?? /.*/).test(text);
15
+
16
+ /** The digit array is always `length` long, blank-padded on the right. */
17
+ const padTo = (digits: string[], length: number) =>
18
+ Array.from({ length }, (_, i) => digits[i] ?? '');
19
+
20
+ interface OtpInput {
21
+ variant: string;
22
+ length: number;
23
+ value: string;
24
+ onChange?: (value: string) => void;
25
+ onComplete?: (value: string) => void;
26
+ }
27
+
28
+ /**
29
+ * The digits, and the three ways they change: typing, the keys that move
30
+ * between slots, and a paste into the first one.
31
+ *
32
+ * Focus moves as a consequence of entry, so the input refs live here with the
33
+ * state that drives them.
34
+ */
35
+ export const useOtpDigits = ({ variant, length, value, onChange, onComplete }: OtpInput) => {
36
+ const [digits, setDigits] = useState<string[]>(() => padTo([], length));
37
+ const inputRefs = useRef<(globalThis.HTMLInputElement | null)[]>([]);
38
+
39
+ useEffect(() => {
40
+ setDigits(padTo(value.split('').slice(0, length), length));
41
+ }, [value, length]);
42
+
43
+ const focusSlot = useCallback((index: number) => {
44
+ inputRefs.current[index]?.focus();
45
+ }, []);
46
+
47
+ /** Publishes the joined value, and reports completion when every slot is full. */
48
+ const publish = useCallback(
49
+ (next: string) => {
50
+ onChange?.(next);
51
+ if (next.length === length) {
52
+ onComplete?.(next);
53
+ }
54
+ },
55
+ [length, onChange, onComplete],
56
+ );
57
+
58
+ const handleInputChange = useCallback(
59
+ (index: number, inputValue: string) => {
60
+ if (!isAllowed(variant, inputValue)) return;
61
+
62
+ const next = [...digits];
63
+ next[index] = inputValue.slice(-1); // one character per slot
64
+ setDigits(next);
65
+ publish(next.join(''));
66
+
67
+ if (inputValue && index < length - 1) {
68
+ focusSlot(index + 1);
69
+ }
70
+ },
71
+ [variant, digits, length, publish, focusSlot],
72
+ );
73
+
74
+ const handleKeyDown = useCallback(
75
+ (index: number, e: React.KeyboardEvent) => {
76
+ // Backspace in an already-empty slot steps back rather than deleting.
77
+ const back =
78
+ (e.key === 'Backspace' && !digits[index]) || e.key === 'ArrowLeft';
79
+
80
+ if (back && index > 0) {
81
+ focusSlot(index - 1);
82
+ } else if (e.key === 'ArrowRight' && index < length - 1) {
83
+ focusSlot(index + 1);
84
+ }
85
+ },
86
+ [digits, length, focusSlot],
87
+ );
88
+
89
+ const handlePaste = useCallback(
90
+ (e: React.ClipboardEvent) => {
91
+ e.preventDefault();
92
+ const pasted = e.clipboardData.getData('text').slice(0, length);
93
+
94
+ // An empty paste is not a rejection, but it is nothing to apply either.
95
+ if (!pasted || !isAllowed(variant, pasted)) return;
96
+
97
+ setDigits(padTo(pasted.split(''), length));
98
+ publish(pasted);
99
+ },
100
+ [variant, length, publish],
101
+ );
102
+
103
+ return { digits, inputRefs, handleInputChange, handleKeyDown, handlePaste };
104
+ };
@@ -0,0 +1,97 @@
1
+ import { alpha } from '@mui/material';
2
+ import type { CSSObject, Theme } from '@mui/material';
3
+
4
+ interface ColorPalette {
5
+ main: string;
6
+ dark?: string;
7
+ light?: string;
8
+ contrastText?: string;
9
+ }
10
+
11
+ /** `neutral` is not a MUI palette entry, so it is built from the grey ramp. */
12
+ const neutralPalette = (theme: Theme): ColorPalette => ({
13
+ main: theme.palette.grey[700],
14
+ dark: theme.palette.grey[800],
15
+ light: theme.palette.grey[500],
16
+ contrastText: '#fff',
17
+ });
18
+
19
+ /** `danger` is this component's name for the error palette. */
20
+ const namedPalette = (theme: Theme, color: string): ColorPalette => {
21
+ const colorMap: Record<string, ColorPalette> = {
22
+ primary: theme.palette.primary,
23
+ secondary: theme.palette.secondary,
24
+ success: theme.palette.success,
25
+ warning: theme.palette.warning,
26
+ danger: theme.palette.error,
27
+ };
28
+
29
+ return colorMap[color] || theme.palette.primary;
30
+ };
31
+
32
+ const getColorFromTheme = (theme: Theme, color: string): ColorPalette => {
33
+ if (color === 'neutral') {
34
+ return neutralPalette(theme);
35
+ }
36
+
37
+ const palette = namedPalette(theme, color);
38
+ const { primary } = theme.palette;
39
+
40
+ return {
41
+ main: palette.main || primary.main,
42
+ dark: palette.dark || palette.main || primary.dark,
43
+ light: palette.light || palette.main || primary.light,
44
+ contrastText: palette.contrastText || '#fff',
45
+ };
46
+ };
47
+
48
+ type SizeKey = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
49
+
50
+ /** Each slot is a square, so width and height always match. */
51
+ const SIZES: Record<SizeKey, { side: string; fontSize: string }> = {
52
+ xs: { side: '32px', fontSize: '0.75rem' },
53
+ sm: { side: '40px', fontSize: '0.875rem' },
54
+ md: { side: '48px', fontSize: '1rem' },
55
+ lg: { side: '56px', fontSize: '1.125rem' },
56
+ xl: { side: '64px', fontSize: '1.25rem' },
57
+ };
58
+
59
+ export interface OtpSlotFlags {
60
+ customColor?: string;
61
+ customSize?: string;
62
+ glass?: boolean;
63
+ gradient?: boolean;
64
+ }
65
+
66
+ export const otpSlotSx = (theme: Theme, flags: OtpSlotFlags): CSSObject => {
67
+ const { customColor = 'primary', customSize = 'md', glass, gradient } = flags;
68
+ const palette = getColorFromTheme(theme, customColor);
69
+ const { side, fontSize } = SIZES[customSize as SizeKey] ?? SIZES.md;
70
+
71
+ return {
72
+ width: side,
73
+ height: side,
74
+ '& .MuiOutlinedInput-root': {
75
+ width: side,
76
+ height: side,
77
+ fontSize,
78
+ fontWeight: 600,
79
+ textAlign: 'center',
80
+ borderRadius: theme.spacing(1),
81
+ ...(glass && {
82
+ backgroundColor: alpha(theme.palette.background.paper, 0.1),
83
+ backdropFilter: 'blur(20px)',
84
+ '& fieldset': { border: `1px solid ${alpha(theme.palette.divider, 0.2)}` },
85
+ }),
86
+ ...(gradient && {
87
+ '&.Mui-focused fieldset': {
88
+ background: `linear-gradient(135deg, ${palette.main}, ${palette.light})`,
89
+ borderWidth: '2px',
90
+ },
91
+ }),
92
+ '& input': { textAlign: 'center', padding: 0, fontWeight: 'inherit' },
93
+ '&:hover fieldset': { borderColor: palette.main },
94
+ '&.Mui-focused fieldset': { borderColor: palette.main, borderWidth: '2px' },
95
+ },
96
+ };
97
+ };