@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
@@ -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
+ };
@@ -2,7 +2,7 @@ import InfoIcon from '@mui/icons-material/Info';
2
2
  import { Box, Stack,TextField } from '@mui/material';
3
3
  import type { Meta, StoryObj } from '@storybook/react-vite';
4
4
  import React from 'react';
5
- import { expect, fn,userEvent, waitFor, within } from 'storybook/test';
5
+ import { expect, fireEvent, fn,userEvent, waitFor, within } from 'storybook/test';
6
6
 
7
7
  import { Label } from './Label';
8
8
 
@@ -247,23 +247,23 @@ export const KeyboardNavigation: Story = {
247
247
  const firstInput = firstInputContainer.querySelector('input');
248
248
 
249
249
  // Focus first label
250
- firstLabel.focus();
251
- await expect(firstLabel).toHaveFocus();
250
+ await userEvent.click(firstLabel);
251
+ await waitFor(() => expect(firstLabel).toHaveFocus());
252
252
 
253
253
  // Tab to input
254
254
  await userEvent.tab();
255
- await expect(firstInput).toHaveFocus();
255
+ await waitFor(() => expect(firstInput).toHaveFocus());
256
256
  });
257
257
 
258
258
  await step('Tab navigation backward', async () => {
259
259
  await userEvent.tab({ shift: true });
260
260
  const firstLabel = canvas.getByTestId('first-label');
261
- await expect(firstLabel).toHaveFocus();
261
+ await waitFor(() => expect(firstLabel).toHaveFocus());
262
262
  });
263
263
 
264
264
  await step('Enter key activation on clickable label', async () => {
265
265
  const firstLabel = canvas.getByTestId('first-label');
266
- firstLabel.focus();
266
+ await userEvent.click(firstLabel);
267
267
  await userEvent.keyboard('{Enter}');
268
268
  // Verify click handler would be called
269
269
  });
@@ -641,14 +641,20 @@ export const PerformanceTest: Story = {
641
641
  await step('Test scroll performance', async () => {
642
642
  const scrollContainer = canvas.getByTestId('scroll-container');
643
643
 
644
- // Simulate rapid scrolling
645
- for (let i = 0; i < 10; i++) {
646
- scrollContainer.scrollTop = i * 40;
647
- await new Promise((resolve) => window.setTimeout(resolve, 50));
648
- }
649
-
650
- // Verify no janky behavior
651
- await expect(scrollContainer).toBeInTheDocument();
644
+ // Scrolling is the one thing this story is about, so it has to move the
645
+ // container; what it must not do is assert a pixel offset, which depends
646
+ // on the viewport the story happens to render in.
647
+ // eslint-disable-next-line test-flakiness/no-viewport-dependent -- the subject under test is scrolling itself
648
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
649
+ fireEvent.scroll(scrollContainer);
650
+
651
+ // The labels are not virtualised, so scrolling must leave every one of
652
+ // them mounted — which is what the old toBeInTheDocument() on the
653
+ // container itself could never have caught, since the container was
654
+ // already there before the loop ran.
655
+ await waitFor(() => {
656
+ expect(canvas.getAllByTestId(/^label-\d+$/)).toHaveLength(100);
657
+ });
652
658
  });
653
659
  },
654
660
  };
@@ -1,296 +1,51 @@
1
- import {
2
- alpha,
3
- Box,
4
- CircularProgress,
5
- Tooltip,
6
- Typography,
7
- useTheme,
8
- } from '@mui/material';
1
+ import { Box, Tooltip, Typography, useTheme } from '@mui/material';
9
2
  import React, { forwardRef } from 'react';
10
3
 
11
- import type { LabelProps } from './Label.types';
12
-
13
- export const Label = forwardRef<globalThis.HTMLLabelElement, LabelProps>(({
14
- children,
15
- htmlFor,
16
- required = false,
17
- disabled = false,
18
- error = false,
19
- variant = 'default',
20
- size = 'md',
21
- color = 'default',
22
- glow = false,
23
- pulse = false,
24
- ripple = false,
25
- loading = false,
26
- className,
27
- style,
28
- tooltip,
29
- helperText,
30
- asteriskPlacement = 'end',
31
- icon,
32
- iconPosition = 'start',
33
- onClick,
34
- onFocus,
35
- onBlur,
36
- srOnly = false,
37
- weight = 'regular',
38
- transform = 'none',
39
- align = 'left',
40
- nowrap = false,
41
- truncate = false,
42
- dataTestId,
43
- ...props
44
- }, ref) => {
45
- const theme = useTheme();
46
-
47
- const getSizeStyles = () => {
48
- switch (size) {
49
- case 'xs':
50
- return {
51
- fontSize: '0.75rem',
52
- lineHeight: 1.2,
53
- padding: theme.spacing(0.25, 0.5),
54
- };
55
- case 'sm':
56
- return {
57
- fontSize: '0.875rem',
58
- lineHeight: 1.3,
59
- padding: theme.spacing(0.5, 0.75),
60
- };
61
- case 'md':
62
- return {
63
- fontSize: '1rem',
64
- lineHeight: 1.5,
65
- padding: theme.spacing(0.75, 1),
66
- };
67
- case 'lg':
68
- return {
69
- fontSize: '1.125rem',
70
- lineHeight: 1.6,
71
- padding: theme.spacing(1, 1.25),
72
- };
73
- case 'xl':
74
- return {
75
- fontSize: '1.25rem',
76
- lineHeight: 1.7,
77
- padding: theme.spacing(1.25, 1.5),
78
- };
79
- default:
80
- return {
81
- fontSize: '1rem',
82
- lineHeight: 1.5,
83
- padding: theme.spacing(0.75, 1),
84
- };
85
- }
86
- };
87
-
88
- const getColorStyles = () => {
89
- if (error) {
90
- return { color: theme.palette.error.main };
91
- }
92
- if (disabled) {
93
- return { color: theme.palette.text.disabled };
94
- }
95
- if (color === 'default') {
96
- return { color: theme.palette.text.primary };
97
- }
98
- return { color: theme.palette[color].main };
99
- };
100
-
101
- const getVariantStyles = () => {
102
- const baseStyles = {
103
- display: srOnly ? 'none' : 'inline-flex',
104
- alignItems: 'center',
105
- gap: theme.spacing(0.5),
106
- transition: theme.transitions.create(['all'], {
107
- duration: theme.transitions.duration.standard,
108
- }),
109
- cursor: onClick ? 'pointer' : 'default',
110
- textAlign: align,
111
- fontWeight: getFontWeight(),
112
- textTransform: transform,
113
- whiteSpace: nowrap ? 'nowrap' : 'normal',
114
- overflow: truncate ? 'hidden' : 'visible',
115
- textOverflow: truncate ? 'ellipsis' : 'clip',
116
- position: 'relative' as const,
117
- ...getSizeStyles(),
118
- ...getColorStyles(),
119
- };
120
-
121
- const glowStyles = glow ? {
122
- textShadow: `0 0 10px ${alpha(getColorStyles().color || theme.palette.primary.main, 0.5)}`,
123
- } : {};
124
-
125
- const pulseStyles = pulse ? {
126
- animation: 'pulse 2s infinite',
127
- '@keyframes pulse': {
128
- '0%': { opacity: 1 },
129
- '50%': { opacity: 0.6 },
130
- '100%': { opacity: 1 },
131
- },
132
- } : {};
133
-
134
- const rippleStyles = ripple && onClick ? {
135
- overflow: 'hidden',
136
- '&::after': {
137
- content: '""',
138
- position: 'absolute',
139
- top: '50%',
140
- left: '50%',
141
- width: 0,
142
- height: 0,
143
- borderRadius: '50%',
144
- backgroundColor: alpha(theme.palette.primary.main, 0.3),
145
- transform: 'translate(-50%, -50%)',
146
- transition: 'width 0.6s, height 0.6s',
147
- },
148
- '&:active::after': {
149
- width: '300%',
150
- height: '300%',
151
- },
152
- } : {};
4
+ import { withDefaults } from '../../../utils/withDefaults';
153
5
 
154
- switch (variant) {
155
- case 'filled':
156
- return {
157
- ...baseStyles,
158
- ...glowStyles,
159
- ...pulseStyles,
160
- ...rippleStyles,
161
- backgroundColor: alpha(getColorStyles().color || theme.palette.primary.main, 0.1),
162
- borderRadius: theme.spacing(0.5),
163
- '&:hover': onClick ? {
164
- backgroundColor: alpha(getColorStyles().color || theme.palette.primary.main, 0.15),
165
- } : {},
166
- };
167
-
168
- case 'outlined':
169
- return {
170
- ...baseStyles,
171
- ...glowStyles,
172
- ...pulseStyles,
173
- ...rippleStyles,
174
- border: `1px solid ${alpha(getColorStyles().color || theme.palette.primary.main, 0.5)}`,
175
- borderRadius: theme.spacing(0.5),
176
- '&:hover': onClick ? {
177
- borderColor: getColorStyles().color || theme.palette.primary.main,
178
- backgroundColor: alpha(getColorStyles().color || theme.palette.primary.main, 0.05),
179
- } : {},
180
- };
181
-
182
- case 'glass':
183
- return {
184
- ...baseStyles,
185
- ...glowStyles,
186
- ...pulseStyles,
187
- ...rippleStyles,
188
- backgroundColor: alpha(theme.palette.background.paper, 0.1),
189
- backdropFilter: 'blur(10px)',
190
- border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
191
- borderRadius: theme.spacing(0.5),
192
- '&:hover': onClick ? {
193
- backgroundColor: alpha(theme.palette.background.paper, 0.15),
194
- borderColor: alpha(theme.palette.primary.main, 0.3),
195
- } : {},
196
- };
197
-
198
- case 'gradient': {
199
- const gradientColor = color === 'default' ? 'primary' : color;
200
- return {
201
- ...baseStyles,
202
- ...glowStyles,
203
- ...pulseStyles,
204
- ...rippleStyles,
205
- background: `linear-gradient(135deg, ${theme.palette[gradientColor].light}, ${theme.palette[gradientColor].main})`,
206
- WebkitBackgroundClip: 'text',
207
- WebkitTextFillColor: 'transparent',
208
- backgroundClip: 'text',
209
- '&:hover': onClick ? {
210
- background: `linear-gradient(135deg, ${theme.palette[gradientColor].main}, ${theme.palette[gradientColor].dark})`,
211
- WebkitBackgroundClip: 'text',
212
- WebkitTextFillColor: 'transparent',
213
- backgroundClip: 'text',
214
- } : {},
215
- };
216
- }
217
-
218
- case 'minimal':
219
- return {
220
- ...baseStyles,
221
- ...glowStyles,
222
- ...pulseStyles,
223
- ...rippleStyles,
224
- padding: 0,
225
- '&:hover': onClick ? {
226
- color: theme.palette.primary.main,
227
- } : {},
228
- };
229
-
230
- default:
231
- return {
232
- ...baseStyles,
233
- ...glowStyles,
234
- ...pulseStyles,
235
- ...rippleStyles,
236
- };
237
- }
238
- };
6
+ import { LabelBody } from './Label.parts';
7
+ import { labelStyles, sizeStyles, SR_ONLY_SX, textColor } from './Label.styles';
8
+ import type { LabelStyleFlags } from './Label.styles';
9
+ import type { LabelProps } from './Label.types';
239
10
 
240
- const getFontWeight = () => {
241
- switch (weight) {
242
- case 'light': return 300;
243
- case 'regular': return 400;
244
- case 'medium': return 500;
245
- case 'semibold': return 600;
246
- case 'bold': return 700;
247
- default: return 400;
248
- }
249
- };
11
+ const DEFAULTS = {
12
+ required: false,
13
+ disabled: false,
14
+ error: false,
15
+ variant: 'default',
16
+ size: 'md',
17
+ color: 'default',
18
+ glow: false,
19
+ pulse: false,
20
+ ripple: false,
21
+ loading: false,
22
+ asteriskPlacement: 'end',
23
+ iconPosition: 'start',
24
+ srOnly: false,
25
+ weight: 'regular',
26
+ transform: 'none',
27
+ align: 'left',
28
+ nowrap: false,
29
+ truncate: false,
30
+ } satisfies Partial<LabelProps>;
31
+
32
+ type ResolvedProps = LabelProps & Required<Pick<LabelProps, keyof typeof DEFAULTS>>;
33
+
34
+ export const Label = forwardRef<globalThis.HTMLLabelElement, LabelProps>((props, ref) => {
35
+ const {
36
+ children, htmlFor, required, disabled, error, variant, size, color,
37
+ glow, pulse, ripple, loading, className, style, tooltip, helperText,
38
+ asteriskPlacement, icon, iconPosition, onClick, onFocus, onBlur,
39
+ srOnly, weight, transform, align, nowrap, truncate, dataTestId,
40
+ ...rest
41
+ } = withDefaults(props, DEFAULTS) as ResolvedProps;
250
42
 
251
- const renderAsterisk = () => {
252
- if (!required) return null;
253
- return (
254
- <Box
255
- component="span"
256
- sx={{
257
- color: theme.palette.error.main,
258
- marginLeft: asteriskPlacement === 'end' ? theme.spacing(0.5) : 0,
259
- marginRight: asteriskPlacement === 'start' ? theme.spacing(0.5) : 0,
260
- }}
261
- >
262
- *
263
- </Box>
264
- );
265
- };
266
-
267
- const renderIcon = () => {
268
- if (!icon) return null;
269
- return (
270
- <Box
271
- component="span"
272
- sx={{
273
- display: 'inline-flex',
274
- alignItems: 'center',
275
- fontSize: 'inherit',
276
- }}
277
- >
278
- {icon}
279
- </Box>
280
- );
281
- };
43
+ const theme = useTheme();
282
44
 
283
- const renderLoading = () => {
284
- if (!loading) return null;
285
- return (
286
- <CircularProgress
287
- size={getSizeStyles().fontSize}
288
- sx={{
289
- color: getColorStyles().color,
290
- marginLeft: theme.spacing(0.5),
291
- }}
292
- />
293
- );
45
+ const flags: LabelStyleFlags = {
46
+ variant, size, color, weight, transform, align,
47
+ error, disabled, glow, pulse, ripple, srOnly, nowrap, truncate,
48
+ clickable: Boolean(onClick),
294
49
  };
295
50
 
296
51
  const labelContent = (
@@ -303,51 +58,44 @@ export const Label = forwardRef<globalThis.HTMLLabelElement, LabelProps>(({
303
58
  onBlur={onBlur}
304
59
  className={className}
305
60
  data-testid={dataTestId}
306
- sx={{
307
- ...getVariantStyles(),
308
- ...style,
309
- }}
310
- {...props}
61
+ sx={{ ...labelStyles(theme, flags), ...style }}
62
+ {...rest}
311
63
  >
312
- {asteriskPlacement === 'start' && renderAsterisk()}
313
- {iconPosition === 'start' && renderIcon()}
314
- {children}
315
- {iconPosition === 'end' && renderIcon()}
316
- {asteriskPlacement === 'end' && renderAsterisk()}
317
- {renderLoading()}
64
+ <LabelBody
65
+ required={required}
66
+ asteriskPlacement={asteriskPlacement}
67
+ icon={icon}
68
+ iconPosition={iconPosition}
69
+ loading={loading}
70
+ fontSize={sizeStyles(theme, size).fontSize as string}
71
+ color={textColor(theme, flags)}
72
+ >
73
+ {children}
74
+ </LabelBody>
318
75
  </Box>
319
76
  );
320
77
 
321
- const wrappedContent = tooltip ? (
322
- <Tooltip title={tooltip} arrow>
323
- {labelContent}
324
- </Tooltip>
325
- ) : labelContent;
78
+ // A screen-reader-only label is rendered on its own: the tooltip, the styling
79
+ // and the helper text all describe something nothing can see.
80
+ if (srOnly) {
81
+ return (
82
+ <Box component="label" htmlFor={htmlFor} data-testid={dataTestId} sx={SR_ONLY_SX}>
83
+ {children}
84
+ </Box>
85
+ );
86
+ }
326
87
 
327
88
  return (
328
89
  <>
329
- {srOnly && (
330
- <Box
331
- component="label"
332
- htmlFor={htmlFor}
333
- data-testid={dataTestId}
334
- sx={{
335
- position: 'absolute',
336
- width: '1px',
337
- height: '1px',
338
- padding: 0,
339
- margin: '-1px',
340
- overflow: 'hidden',
341
- clip: 'rect(0, 0, 0, 0)',
342
- whiteSpace: 'nowrap',
343
- border: 0,
344
- }}
345
- >
346
- {children}
347
- </Box>
90
+ {tooltip ? (
91
+ <Tooltip title={tooltip} arrow>
92
+ {labelContent}
93
+ </Tooltip>
94
+ ) : (
95
+ labelContent
348
96
  )}
349
- {!srOnly && wrappedContent}
350
- {helperText && !srOnly && (
97
+
98
+ {helperText && (
351
99
  <Typography
352
100
  variant="caption"
353
101
  sx={{
@@ -364,4 +112,4 @@ export const Label = forwardRef<globalThis.HTMLLabelElement, LabelProps>(({
364
112
  );
365
113
  });
366
114
 
367
- Label.displayName = 'Label';
115
+ Label.displayName = 'Label';