@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.
- package/package.json +4 -4
- 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.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.tsx +125 -948
- package/src/components/feedback/Modal/Modal.styles.ts +126 -0
- package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
- package/src/components/feedback/Modal/Modal.tsx +37 -242
- 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.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/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/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
|
@@ -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
|
+
);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { alpha } from '@mui/material';
|
|
2
|
+
import type { CSSObject, Theme } from '@mui/material';
|
|
3
|
+
|
|
4
|
+
import type { LabelProps } from './Label.types';
|
|
5
|
+
|
|
6
|
+
type Size = NonNullable<LabelProps['size']>;
|
|
7
|
+
type Variant = NonNullable<LabelProps['variant']>;
|
|
8
|
+
type Weight = NonNullable<LabelProps['weight']>;
|
|
9
|
+
|
|
10
|
+
const SIZES: Record<Size, (theme: Theme) => CSSObject> = {
|
|
11
|
+
xs: (theme) => ({ fontSize: '0.75rem', lineHeight: 1.2, padding: theme.spacing(0.25, 0.5) }),
|
|
12
|
+
sm: (theme) => ({ fontSize: '0.875rem', lineHeight: 1.3, padding: theme.spacing(0.5, 0.75) }),
|
|
13
|
+
md: (theme) => ({ fontSize: '1rem', lineHeight: 1.5, padding: theme.spacing(0.75, 1) }),
|
|
14
|
+
lg: (theme) => ({ fontSize: '1.125rem', lineHeight: 1.6, padding: theme.spacing(1, 1.25) }),
|
|
15
|
+
xl: (theme) => ({ fontSize: '1.25rem', lineHeight: 1.7, padding: theme.spacing(1.25, 1.5) }),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const sizeStyles = (theme: Theme, size: Size): CSSObject => (SIZES[size] ?? SIZES.md)(theme);
|
|
19
|
+
|
|
20
|
+
const WEIGHTS: Record<Weight, number> = {
|
|
21
|
+
light: 300,
|
|
22
|
+
regular: 400,
|
|
23
|
+
medium: 500,
|
|
24
|
+
semibold: 600,
|
|
25
|
+
bold: 700,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const fontWeight = (weight: Weight) => WEIGHTS[weight] ?? WEIGHTS.regular;
|
|
29
|
+
|
|
30
|
+
export interface LabelStyleFlags {
|
|
31
|
+
variant: Variant;
|
|
32
|
+
size: Size;
|
|
33
|
+
color: NonNullable<LabelProps['color']>;
|
|
34
|
+
weight: Weight;
|
|
35
|
+
transform: NonNullable<LabelProps['transform']>;
|
|
36
|
+
align: NonNullable<LabelProps['align']>;
|
|
37
|
+
error: boolean;
|
|
38
|
+
disabled: boolean;
|
|
39
|
+
glow: boolean;
|
|
40
|
+
pulse: boolean;
|
|
41
|
+
ripple: boolean;
|
|
42
|
+
srOnly: boolean;
|
|
43
|
+
nowrap: boolean;
|
|
44
|
+
truncate: boolean;
|
|
45
|
+
clickable: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The text colour, which error and disabled override before the palette is
|
|
50
|
+
* consulted at all. Resolved once and threaded through — the variant styles
|
|
51
|
+
* read it up to four times each.
|
|
52
|
+
*/
|
|
53
|
+
export const textColor = (theme: Theme, flags: LabelStyleFlags): string => {
|
|
54
|
+
if (flags.error) return theme.palette.error.main;
|
|
55
|
+
if (flags.disabled) return theme.palette.text.disabled;
|
|
56
|
+
if (flags.color === 'default') return theme.palette.text.primary;
|
|
57
|
+
return theme.palette[flags.color].main;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The ripple that expands from the centre on press, for clickable labels. */
|
|
61
|
+
const rippleStyles = (theme: Theme): CSSObject => ({
|
|
62
|
+
overflow: 'hidden',
|
|
63
|
+
'&::after': {
|
|
64
|
+
content: '""',
|
|
65
|
+
position: 'absolute',
|
|
66
|
+
top: '50%',
|
|
67
|
+
left: '50%',
|
|
68
|
+
width: 0,
|
|
69
|
+
height: 0,
|
|
70
|
+
borderRadius: '50%',
|
|
71
|
+
backgroundColor: alpha(theme.palette.primary.main, 0.3),
|
|
72
|
+
transform: 'translate(-50%, -50%)',
|
|
73
|
+
transition: 'width 0.6s, height 0.6s',
|
|
74
|
+
},
|
|
75
|
+
'&:active::after': { width: '300%', height: '300%' },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
interface SurfaceInput {
|
|
79
|
+
theme: Theme;
|
|
80
|
+
flags: LabelStyleFlags;
|
|
81
|
+
/** The resolved text colour, or the primary accent when it resolves to none. */
|
|
82
|
+
accent: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Each variant's own additions. Every arm previously re-spread the same base,
|
|
87
|
+
* glow, pulse and ripple objects and re-derived the colour for each use; the
|
|
88
|
+
* shared prefix is applied once by `labelStyles`.
|
|
89
|
+
*
|
|
90
|
+
* The hover rules are only attached when the label is clickable, which is what
|
|
91
|
+
* the `onClick ? {…} : {}` in every arm was saying.
|
|
92
|
+
*/
|
|
93
|
+
const SURFACES: Record<Variant, (input: SurfaceInput) => CSSObject> = {
|
|
94
|
+
filled: ({ theme, flags, accent }) => ({
|
|
95
|
+
backgroundColor: alpha(accent, 0.1),
|
|
96
|
+
borderRadius: theme.spacing(0.5),
|
|
97
|
+
'&:hover': flags.clickable ? { backgroundColor: alpha(accent, 0.15) } : {},
|
|
98
|
+
}),
|
|
99
|
+
|
|
100
|
+
outlined: ({ theme, flags, accent }) => ({
|
|
101
|
+
border: `1px solid ${alpha(accent, 0.5)}`,
|
|
102
|
+
borderRadius: theme.spacing(0.5),
|
|
103
|
+
'&:hover': flags.clickable
|
|
104
|
+
? { borderColor: accent, backgroundColor: alpha(accent, 0.05) }
|
|
105
|
+
: {},
|
|
106
|
+
}),
|
|
107
|
+
|
|
108
|
+
glass: ({ theme, flags }) => ({
|
|
109
|
+
backgroundColor: alpha(theme.palette.background.paper, 0.1),
|
|
110
|
+
backdropFilter: 'blur(10px)',
|
|
111
|
+
border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
|
|
112
|
+
borderRadius: theme.spacing(0.5),
|
|
113
|
+
'&:hover': flags.clickable
|
|
114
|
+
? {
|
|
115
|
+
backgroundColor: alpha(theme.palette.background.paper, 0.15),
|
|
116
|
+
borderColor: alpha(theme.palette.primary.main, 0.3),
|
|
117
|
+
}
|
|
118
|
+
: {},
|
|
119
|
+
}),
|
|
120
|
+
|
|
121
|
+
gradient: ({ theme, flags }) => {
|
|
122
|
+
const key = flags.color === 'default' ? 'primary' : flags.color;
|
|
123
|
+
// The gradient paints the glyphs themselves rather than the box behind them.
|
|
124
|
+
const clip = {
|
|
125
|
+
WebkitBackgroundClip: 'text',
|
|
126
|
+
WebkitTextFillColor: 'transparent',
|
|
127
|
+
backgroundClip: 'text',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
background: `linear-gradient(135deg, ${theme.palette[key].light}, ${theme.palette[key].main})`,
|
|
132
|
+
...clip,
|
|
133
|
+
'&:hover': flags.clickable
|
|
134
|
+
? {
|
|
135
|
+
background: `linear-gradient(135deg, ${theme.palette[key].main}, ${theme.palette[key].dark})`,
|
|
136
|
+
...clip,
|
|
137
|
+
}
|
|
138
|
+
: {},
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
minimal: ({ theme, flags }) => ({
|
|
143
|
+
padding: 0,
|
|
144
|
+
'&:hover': flags.clickable ? { color: theme.palette.primary.main } : {},
|
|
145
|
+
}),
|
|
146
|
+
|
|
147
|
+
default: () => ({}),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** How the text sits: its alignment, weight, casing and overflow behaviour. */
|
|
151
|
+
const textLayout = (theme: Theme, flags: LabelStyleFlags): CSSObject => {
|
|
152
|
+
const { srOnly, clickable, align, weight, transform, nowrap, truncate } = flags;
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
display: srOnly ? 'none' : 'inline-flex',
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
gap: theme.spacing(0.5),
|
|
158
|
+
transition: theme.transitions.create(['all'], {
|
|
159
|
+
duration: theme.transitions.duration.standard,
|
|
160
|
+
}),
|
|
161
|
+
cursor: clickable ? 'pointer' : 'default',
|
|
162
|
+
textAlign: align,
|
|
163
|
+
fontWeight: fontWeight(weight),
|
|
164
|
+
textTransform: transform,
|
|
165
|
+
whiteSpace: nowrap ? 'nowrap' : 'normal',
|
|
166
|
+
overflow: truncate ? 'hidden' : 'visible',
|
|
167
|
+
textOverflow: truncate ? 'ellipsis' : 'clip',
|
|
168
|
+
position: 'relative' as const,
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const labelStyles = (theme: Theme, flags: LabelStyleFlags): CSSObject => {
|
|
173
|
+
const { variant, size, clickable } = flags;
|
|
174
|
+
const color = textColor(theme, flags);
|
|
175
|
+
const accent = color || theme.palette.primary.main;
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
...textLayout(theme, flags),
|
|
179
|
+
...sizeStyles(theme, size),
|
|
180
|
+
color,
|
|
181
|
+
...(flags.glow && { textShadow: `0 0 10px ${alpha(accent, 0.5)}` }),
|
|
182
|
+
...(flags.pulse && {
|
|
183
|
+
animation: 'pulse 2s infinite',
|
|
184
|
+
'@keyframes pulse': {
|
|
185
|
+
'0%': { opacity: 1 },
|
|
186
|
+
'50%': { opacity: 0.6 },
|
|
187
|
+
'100%': { opacity: 1 },
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
...(flags.ripple && clickable && rippleStyles(theme)),
|
|
191
|
+
...(SURFACES[variant] ?? SURFACES.default)({ theme, flags, accent }),
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** Visually hidden but still read aloud, for the `srOnly` label. */
|
|
196
|
+
export const SR_ONLY_SX: CSSObject = {
|
|
197
|
+
position: 'absolute',
|
|
198
|
+
width: '1px',
|
|
199
|
+
height: '1px',
|
|
200
|
+
padding: 0,
|
|
201
|
+
margin: '-1px',
|
|
202
|
+
overflow: 'hidden',
|
|
203
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
204
|
+
whiteSpace: 'nowrap',
|
|
205
|
+
border: 0,
|
|
206
|
+
};
|