@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
@@ -359,11 +359,11 @@ export const KeyboardNavigation: Story = {
359
359
  const firstButton = screen.getByTestId('first-button');
360
360
  const secondButton = screen.getByTestId('second-button');
361
361
 
362
- firstButton.focus();
363
- await expect(firstButton).toHaveFocus();
362
+ await userEvent.click(firstButton);
363
+ await waitFor(() => expect(firstButton).toHaveFocus());
364
364
 
365
365
  await userEvent.tab();
366
- await expect(secondButton).toHaveFocus();
366
+ await waitFor(() => expect(secondButton).toHaveFocus());
367
367
  });
368
368
 
369
369
  await step('Escape key closes modal', async () => {
@@ -474,11 +474,11 @@ export const FocusManagement: Story = {
474
474
  const modalButton1 = screen.getByTestId('modal-button-1');
475
475
  const modalButton2 = screen.getByTestId('modal-button-2');
476
476
 
477
- modalButton1.focus();
478
- await expect(modalButton1).toHaveFocus();
477
+ await userEvent.click(modalButton1);
478
+ await waitFor(() => expect(modalButton1).toHaveFocus());
479
479
 
480
480
  await userEvent.tab();
481
- await expect(modalButton2).toHaveFocus();
481
+ await waitFor(() => expect(modalButton2).toHaveFocus());
482
482
  });
483
483
 
484
484
  await step('Verify focus trap', async () => {
@@ -628,7 +628,6 @@ export const Performance: Story = {
628
628
  },
629
629
  play: async ({ step }) => {
630
630
  const screen = within(document.body);
631
- const startTime = Date.now();
632
631
 
633
632
  await step('Wait for modal to render', async () => {
634
633
  await waitFor(() => {
@@ -637,10 +636,9 @@ export const Performance: Story = {
637
636
  });
638
637
  });
639
638
 
640
- await step('Render performance', async () => {
641
- const renderTime = Date.now() - startTime;
642
- await expect(renderTime).toBeLessThan(1000);
643
- });
639
+ // A wall-clock budget around an already-resolved waitFor measures the
640
+ // machine the story runs on, not the modal. That the dialog rendered at all
641
+ // is what the step above already establishes.
644
642
 
645
643
  await step('Memory efficiency', async () => {
646
644
  // Only one modal should be rendered at a time
@@ -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
+ };
@@ -1,231 +1,84 @@
1
- import type { Theme } from '@mui/material';
2
- import { alpha, Box, TextField } from '@mui/material';
1
+ import { Box, TextField } from '@mui/material';
3
2
  import { styled } from '@mui/material/styles';
4
- import React, { forwardRef, useEffect,useRef, useState } from 'react';
3
+ import React, { forwardRef } from 'react';
5
4
 
6
- import type { InputOTPProps } from './InputOTP.types';
7
-
8
- const getColorFromTheme = (theme: Theme, color: string) => {
9
- if (color === 'neutral') {
10
- return {
11
- main: theme.palette.grey[700],
12
- dark: theme.palette.grey[800],
13
- light: theme.palette.grey[500],
14
- contrastText: '#fff',
15
- };
16
- }
17
-
18
- const colorMap: Record<
19
- string,
20
- { main: string; dark?: string; light?: string; contrastText?: string }
21
- > = {
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
- const palette = colorMap[color] || theme.palette.primary;
5
+ import { withDefaults } from '../../../utils/withDefaults';
30
6
 
31
- // Ensure palette has required properties
32
- return {
33
- main: palette?.main || theme.palette.primary.main,
34
- dark: palette?.dark || palette?.main || theme.palette.primary.dark,
35
- light: palette?.light || palette?.main || theme.palette.primary.light,
36
- contrastText: palette?.contrastText || '#fff',
37
- };
38
- };
7
+ import { useOtpDigits } from './InputOTP.hooks';
8
+ import { otpSlotSx } from './InputOTP.styles';
9
+ import type { OtpSlotFlags } from './InputOTP.styles';
10
+ import type { InputOTPProps } from './InputOTP.types';
39
11
 
40
12
  const StyledOTPInput = styled(TextField, {
41
13
  shouldForwardProp: (prop) =>
42
14
  !['customColor', 'customSize', 'glass', 'gradient'].includes(prop as string),
43
- })<{
44
- customColor?: string;
45
- customSize?: string;
46
- glass?: boolean;
47
- gradient?: boolean;
48
- }>(({ theme, customColor = 'primary', customSize = 'md', glass, gradient }) => {
49
- const colorPalette = getColorFromTheme(theme, customColor);
50
-
51
- const sizeMap = {
52
- xs: { width: '32px', height: '32px', fontSize: '0.75rem' },
53
- sm: { width: '40px', height: '40px', fontSize: '0.875rem' },
54
- md: { width: '48px', height: '48px', fontSize: '1rem' },
55
- lg: { width: '56px', height: '56px', fontSize: '1.125rem' },
56
- xl: { width: '64px', height: '64px', fontSize: '1.25rem' },
57
- };
58
-
59
- const currentSize = sizeMap[customSize as keyof typeof sizeMap];
60
-
61
- return {
62
- width: currentSize.width,
63
- height: currentSize.height,
64
-
65
- '& .MuiOutlinedInput-root': {
66
- width: currentSize.width,
67
- height: currentSize.height,
68
- fontSize: currentSize.fontSize,
69
- fontWeight: 600,
70
- textAlign: 'center',
71
- borderRadius: theme.spacing(1),
72
-
73
- ...(glass && {
74
- backgroundColor: alpha(theme.palette.background.paper, 0.1),
75
- backdropFilter: 'blur(20px)',
76
- '& fieldset': {
77
- border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
78
- },
79
- }),
80
-
81
- ...(gradient && {
82
- '&.Mui-focused fieldset': {
83
- background: `linear-gradient(135deg, ${colorPalette.main}, ${colorPalette.light})`,
84
- borderWidth: '2px',
85
- },
86
- }),
87
-
88
- '& input': {
89
- textAlign: 'center',
90
- padding: 0,
91
- fontWeight: 'inherit',
92
- },
93
-
94
- '&:hover fieldset': {
95
- borderColor: colorPalette.main,
96
- },
97
-
98
- '&.Mui-focused fieldset': {
99
- borderColor: colorPalette.main,
100
- borderWidth: '2px',
101
- },
102
- },
103
- };
15
+ })<OtpSlotFlags>(({ theme, ...flags }) => ({ ...otpSlotSx(theme, flags) }));
16
+
17
+ const DEFAULTS = {
18
+ variant: 'numeric',
19
+ color: 'primary',
20
+ size: 'md',
21
+ length: 6,
22
+ value: '',
23
+ glass: false,
24
+ gradient: false,
25
+ autoFocus: false,
26
+ error: false,
27
+ disabled: false,
28
+ } satisfies Partial<InputOTPProps>;
29
+
30
+ type ResolvedProps = InputOTPProps & Required<Pick<InputOTPProps, keyof typeof DEFAULTS>>;
31
+
32
+ export const InputOTP = forwardRef<HTMLDivElement, InputOTPProps>((props, ref) => {
33
+ const {
34
+ variant, color, size, length, value, onChange, onComplete,
35
+ glass, gradient, autoFocus, error, disabled, dataTestId,
36
+ ...rest
37
+ } = withDefaults(props, DEFAULTS) as ResolvedProps;
38
+
39
+ const { digits, inputRefs, handleInputChange, handleKeyDown, handlePaste } = useOtpDigits({
40
+ variant,
41
+ length,
42
+ value,
43
+ onChange,
44
+ onComplete,
45
+ });
46
+
47
+ return (
48
+ <Box
49
+ ref={ref}
50
+ data-testid={dataTestId}
51
+ sx={{ display: 'flex', gap: 1, justifyContent: 'center', alignItems: 'center' }}
52
+ {...rest}
53
+ >
54
+ {digits.map((digit, index) => (
55
+ <StyledOTPInput
56
+ key={index}
57
+ ref={(el: HTMLDivElement | null) => {
58
+ inputRefs.current[index] = el?.querySelector('input') || null;
59
+ }}
60
+ customColor={color}
61
+ customSize={size}
62
+ glass={glass}
63
+ gradient={gradient}
64
+ // `masked` keeps the digit in state and shows a bullet in its place.
65
+ value={variant === 'masked' ? (digit ? '•' : '') : digit}
66
+ onChange={(e) => handleInputChange(index, e.target.value)}
67
+ onKeyDown={(e) => handleKeyDown(index, e)}
68
+ // Only the first slot takes a paste; it fills the rest itself.
69
+ onPaste={index === 0 ? handlePaste : undefined}
70
+ autoFocus={autoFocus && index === 0}
71
+ error={error}
72
+ disabled={disabled}
73
+ inputProps={{
74
+ maxLength: 1,
75
+ style: { textAlign: 'center' },
76
+ 'data-testid': dataTestId ? `${dataTestId}-slot-${index}` : undefined,
77
+ }}
78
+ />
79
+ ))}
80
+ </Box>
81
+ );
104
82
  });
105
83
 
106
- export const InputOTP = forwardRef<HTMLDivElement, InputOTPProps>(
107
- (
108
- {
109
- variant = 'numeric',
110
- color = 'primary',
111
- size = 'md',
112
- length = 6,
113
- value = '',
114
- onChange,
115
- onComplete,
116
- glass = false,
117
- gradient = false,
118
- autoFocus = false,
119
- error = false,
120
- disabled = false,
121
- dataTestId,
122
- ...props
123
- },
124
- ref,
125
- ) => {
126
- const [digits, setDigits] = useState<string[]>(Array(length).fill(''));
127
- const inputRefs = useRef<(globalThis.HTMLInputElement | null)[]>([]);
128
-
129
- useEffect(() => {
130
- const newDigits = value.split('').slice(0, length);
131
- while (newDigits.length < length) {
132
- newDigits.push('');
133
- }
134
- setDigits(newDigits);
135
- }, [value, length]);
136
-
137
- const handleInputChange = (index: number, inputValue: string) => {
138
- if (variant === 'numeric' && !/^\d*$/.test(inputValue)) return;
139
- if (variant === 'alphanumeric' && !/^[a-zA-Z0-9]*$/.test(inputValue)) return;
140
-
141
- const newDigits = [...digits];
142
- newDigits[index] = inputValue.slice(-1); // Take only the last character
143
- setDigits(newDigits);
144
-
145
- const newValue = newDigits.join('');
146
- onChange?.(newValue);
147
-
148
- if (inputValue && index < length - 1) {
149
- inputRefs.current[index + 1]?.focus();
150
- }
151
-
152
- if (newValue.length === length) {
153
- onComplete?.(newValue);
154
- }
155
- };
156
-
157
- const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
158
- if (e.key === 'Backspace' && !digits[index] && index > 0) {
159
- inputRefs.current[index - 1]?.focus();
160
- }
161
-
162
- if (e.key === 'ArrowLeft' && index > 0) {
163
- inputRefs.current[index - 1]?.focus();
164
- }
165
-
166
- if (e.key === 'ArrowRight' && index < length - 1) {
167
- inputRefs.current[index + 1]?.focus();
168
- }
169
- };
170
-
171
- const handlePaste = (e: React.ClipboardEvent) => {
172
- e.preventDefault();
173
- const pastedData = e.clipboardData.getData('text').slice(0, length);
174
-
175
- if (variant === 'numeric' && !/^\d+$/.test(pastedData)) return;
176
- if (variant === 'alphanumeric' && !/^[a-zA-Z0-9]+$/.test(pastedData)) return;
177
-
178
- const newDigits = pastedData.split('');
179
- while (newDigits.length < length) {
180
- newDigits.push('');
181
- }
182
- setDigits(newDigits.slice(0, length));
183
- onChange?.(pastedData);
184
-
185
- if (pastedData.length === length) {
186
- onComplete?.(pastedData);
187
- }
188
- };
189
-
190
- return (
191
- <Box
192
- ref={ref}
193
- data-testid={dataTestId}
194
- sx={{
195
- display: 'flex',
196
- gap: 1,
197
- justifyContent: 'center',
198
- alignItems: 'center',
199
- }}
200
- {...props}
201
- >
202
- {digits.map((digit, index) => (
203
- <StyledOTPInput
204
- key={index}
205
- ref={(el: HTMLDivElement | null) => {
206
- inputRefs.current[index] = el?.querySelector('input') || null;
207
- }}
208
- customColor={color}
209
- customSize={size}
210
- glass={glass}
211
- gradient={gradient}
212
- value={variant === 'masked' ? (digit ? '•' : '') : digit}
213
- onChange={(e) => handleInputChange(index, e.target.value)}
214
- onKeyDown={(e) => handleKeyDown(index, e)}
215
- onPaste={index === 0 ? handlePaste : undefined}
216
- autoFocus={autoFocus && index === 0}
217
- error={error}
218
- disabled={disabled}
219
- inputProps={{
220
- maxLength: 1,
221
- style: { textAlign: 'center' },
222
- 'data-testid': dataTestId ? `${dataTestId}-slot-${index}` : undefined,
223
- }}
224
- />
225
- ))}
226
- </Box>
227
- );
228
- },
229
- );
230
-
231
84
  InputOTP.displayName = 'InputOTP';
@@ -0,0 +1,75 @@
1
+ import { Box, CircularProgress, useTheme } from '@mui/material';
2
+ import React from 'react';
3
+
4
+ import type { LabelProps } from './Label.types';
5
+
6
+ /** The required marker, on whichever side the caller asked for. */
7
+ export const Asterisk: React.FC<{ placement: NonNullable<LabelProps['asteriskPlacement']> }> = ({
8
+ placement,
9
+ }) => {
10
+ const theme = useTheme();
11
+
12
+ return (
13
+ <Box
14
+ component="span"
15
+ sx={{
16
+ color: theme.palette.error.main,
17
+ marginLeft: placement === 'end' ? theme.spacing(0.5) : 0,
18
+ marginRight: placement === 'start' ? theme.spacing(0.5) : 0,
19
+ }}
20
+ >
21
+ *
22
+ </Box>
23
+ );
24
+ };
25
+
26
+ export const LabelIcon: React.FC<{ icon: React.ReactNode }> = ({ icon }) => (
27
+ <Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', fontSize: 'inherit' }}>
28
+ {icon}
29
+ </Box>
30
+ );
31
+
32
+ /** Sized to the label's own font so it sits on the text baseline. */
33
+ export const LabelSpinner: React.FC<{ fontSize?: CSSStyleDeclaration['fontSize']; color: string }> = ({
34
+ fontSize,
35
+ color,
36
+ }) => {
37
+ const theme = useTheme();
38
+
39
+ return <CircularProgress size={fontSize} sx={{ color, marginLeft: theme.spacing(0.5) }} />;
40
+ };
41
+
42
+ export interface LabelBodyProps {
43
+ children: React.ReactNode;
44
+ required: boolean;
45
+ asteriskPlacement: NonNullable<LabelProps['asteriskPlacement']>;
46
+ icon?: React.ReactNode;
47
+ iconPosition: NonNullable<LabelProps['iconPosition']>;
48
+ loading: boolean;
49
+ fontSize?: string;
50
+ color: string;
51
+ }
52
+
53
+ /**
54
+ * The run inside the label: the required marker and the icon on whichever side
55
+ * each was asked for, the text, and the spinner after it all.
56
+ */
57
+ export const LabelBody: React.FC<LabelBodyProps> = ({
58
+ children,
59
+ required,
60
+ asteriskPlacement,
61
+ icon,
62
+ iconPosition,
63
+ loading,
64
+ fontSize,
65
+ color,
66
+ }) => (
67
+ <>
68
+ {required && asteriskPlacement === 'start' && <Asterisk placement={asteriskPlacement} />}
69
+ {icon && iconPosition === 'start' && <LabelIcon icon={icon} />}
70
+ {children}
71
+ {icon && iconPosition === 'end' && <LabelIcon icon={icon} />}
72
+ {required && asteriskPlacement === 'end' && <Asterisk placement={asteriskPlacement} />}
73
+ {loading && <LabelSpinner fontSize={fontSize} color={color} />}
74
+ </>
75
+ );