@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
@@ -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
+ );
@@ -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
+ };