@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.
- package/eslint.config.js +12 -0
- package/package.json +5 -4
- package/src/components/data-display/AlertDialog/AlertDialog.test.stories.tsx +16 -6
- package/src/components/data-display/Banner/Banner.test.stories.tsx +26 -18
- package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
- package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
- package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
- package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
- package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
- package/src/components/data-display/Carousel/Carousel.test.stories.tsx +15 -5
- package/src/components/data-display/Carousel/Carousel.tsx +84 -732
- package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
- package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
- package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
- package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
- package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
- package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
- package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
- package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
- package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
- package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
- package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
- package/src/components/data-display/Sheet/Sheet.test.stories.tsx +25 -21
- package/src/components/data-display/Sheet/Sheet.tsx +125 -948
- package/src/components/feedback/Dialog/Dialog.test.stories.tsx +37 -28
- package/src/components/feedback/Modal/Modal.styles.ts +126 -0
- package/src/components/feedback/Modal/Modal.test.stories.tsx +5 -5
- package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
- package/src/components/feedback/Modal/Modal.tsx +37 -242
- package/src/components/feedback/StackedModal/StackedModal.test.stories.tsx +9 -11
- package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
- package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
- package/src/components/form/InputOTP/InputOTP.tsx +74 -221
- package/src/components/form/Label/Label.parts.tsx +75 -0
- package/src/components/form/Label/Label.styles.ts +206 -0
- package/src/components/form/Label/Label.test.stories.tsx +20 -14
- package/src/components/form/Label/Label.tsx +73 -325
- package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
- package/src/components/form/Menubar/Menubar.item.tsx +121 -0
- package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
- package/src/components/form/Menubar/Menubar.styles.ts +110 -0
- package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
- package/src/components/form/Menubar/Menubar.tsx +64 -347
- package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
- package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
- package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
- package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
- package/src/components/form/Slider/Slider.animations.ts +40 -0
- package/src/components/form/Slider/Slider.styles.ts +292 -0
- package/src/components/form/Slider/Slider.tsx +106 -357
- package/src/components/form/Switch/Switch.animations.ts +62 -0
- package/src/components/form/Switch/Switch.parts.tsx +213 -0
- package/src/components/form/Switch/Switch.styles.ts +355 -0
- package/src/components/form/Switch/Switch.tsx +87 -566
- package/src/components/form/ToggleGroup/ToggleGroup.test.stories.tsx +26 -29
- package/src/components/layout/Resizable/Resizable.test.stories.tsx +11 -7
- package/src/components/layout/Spacer/Spacer.test.stories.tsx +11 -9
- package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
- package/src/components/typography/Heading/Heading.styles.ts +101 -0
- package/src/components/typography/Heading/Heading.tsx +48 -164
- package/src/components/typography/Text/Text.test.stories.tsx +27 -30
- package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
- package/src/utils/index.ts +1 -0
- package/src/utils/withDefaults.ts +20 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
|
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
|
|
3
|
+
import React, { forwardRef } from 'react';
|
|
5
4
|
|
|
6
|
-
import
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
);
|