@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
@@ -141,7 +141,7 @@ export const KeyboardNavigation: Story = {
141
141
  const firstButton = canvas.getAllByRole('button')[0];
142
142
 
143
143
  // Focus the first button
144
- firstButton.focus();
144
+ await userEvent.click(firstButton);
145
145
  await waitFor(() => {
146
146
  expect(firstButton).toHaveFocus();
147
147
  });
@@ -206,24 +206,28 @@ export const FocusManagement: Story = {
206
206
  play: async ({ canvasElement }) => {
207
207
  const canvas = within(canvasElement);
208
208
 
209
- const buttons = canvas.getAllByRole('button');
209
+ // Named rather than indexed: a positional lookup silently follows whatever
210
+ // order the options happen to render in, so it keeps passing after a
211
+ // reorder while no longer testing what it says it does.
212
+ const likeButton = canvas.getByRole('button', { name: /like/i });
213
+ const starButton = canvas.getByRole('button', { name: /star/i });
210
214
 
211
215
  // Test initial focus
212
- buttons[0].focus();
216
+ await userEvent.click(likeButton);
213
217
  await waitFor(() => {
214
- expect(buttons[0]).toHaveFocus();
218
+ expect(likeButton).toHaveFocus();
215
219
  });
216
220
 
217
- // Test focus moves correctly with tab navigation
221
+ // Test focus moves correctly with tab navigation
218
222
  await userEvent.tab();
219
223
  await waitFor(() => {
220
- expect(buttons[1]).toHaveFocus();
224
+ expect(starButton).toHaveFocus();
221
225
  });
222
226
 
223
227
  // Test focus doesn't get lost after selection
224
- await userEvent.click(buttons[1]);
228
+ await userEvent.click(starButton);
225
229
  await waitFor(() => {
226
- expect(buttons[1]).toHaveFocus();
230
+ expect(starButton).toHaveFocus();
227
231
  });
228
232
  },
229
233
  };
@@ -390,31 +394,24 @@ export const Performance: Story = {
390
394
  play: async ({ canvasElement }) => {
391
395
  const canvas = within(canvasElement);
392
396
 
393
- const startTime = window.performance.now();
394
-
395
- // Test rendering performance with many options
397
+ // All twenty options render — the assertion the wall-clock budgets that used
398
+ // to sit here were standing in for. A millisecond threshold measures the
399
+ // machine running the story, not the component, and the timings were
400
+ // already padded to 100ms and 600ms to survive CI.
396
401
  const buttons = canvas.getAllByRole('button');
397
402
  expect(buttons).toHaveLength(20);
398
403
 
399
- const renderTime = window.performance.now() - startTime;
400
-
401
- // Should render within reasonable time (less than 100ms for initial query)
402
- expect(renderTime).toBeLessThan(100);
403
-
404
- // Test interaction performance
405
- const interactionStart = window.performance.now();
406
-
407
- // Click multiple buttons rapidly
408
- await userEvent.click(buttons[0]);
409
- await userEvent.click(buttons[5]);
410
- await userEvent.click(buttons[10]);
411
-
412
- const interactionTime = window.performance.now() - interactionStart;
404
+ // Clicking a spread of them, by name, still exercises the multi-select path.
405
+ for (const label of ['Option 1', 'Option 6', 'Option 11']) {
406
+ await userEvent.click(canvas.getByRole('button', { name: new RegExp(label + '$') }));
407
+ }
413
408
 
414
- // Interactions should be responsive (less than 600ms for 3 clicks)
415
- // This accounts for userEvent simulation delays, React state updates,
416
- // browser reflows, and Storybook rendering overhead in CI/CD environments
417
- expect(interactionTime).toBeLessThan(600);
409
+ await waitFor(() => {
410
+ expect(canvas.getByRole('button', { name: /Option 11$/ })).toHaveAttribute(
411
+ 'aria-pressed',
412
+ 'true',
413
+ );
414
+ });
418
415
  },
419
416
  };
420
417
 
@@ -209,16 +209,16 @@ export const KeyboardNavigation: Story = {
209
209
 
210
210
  await step('Content is focusable', async () => {
211
211
  const focusTarget = canvas.getByTestId('focus-target');
212
- focusTarget.focus();
213
- await expect(focusTarget).toHaveFocus();
212
+ await userEvent.click(focusTarget);
213
+ await waitFor(() => expect(focusTarget).toHaveFocus());
214
214
  });
215
215
 
216
216
  await step('Tab navigation works', async () => {
217
217
  const focusTarget = canvas.getByTestId('focus-target');
218
- focusTarget.focus();
218
+ await userEvent.click(focusTarget);
219
219
  await userEvent.tab();
220
220
  // Tab should move focus away from the button
221
- await expect(focusTarget).not.toHaveFocus();
221
+ await waitFor(() => expect(focusTarget).not.toHaveFocus());
222
222
  });
223
223
 
224
224
  await step('Container has proper ARIA attributes', async () => {
@@ -324,9 +324,11 @@ export const ResponsiveDesign: Story = {
324
324
  const content = canvas.getByTestId('responsive-content');
325
325
  await expect(content).toBeInTheDocument();
326
326
 
327
- // Verify the component adapts to smaller screen
327
+ // The container is positioned so the resize handles can sit against its
328
+ // edges. That holds at every width — the old `window.innerWidth <= 768`
329
+ // guard meant this assertion simply did not run on a normal screen.
328
330
  const container = content.closest('[data-testid]')?.parentElement;
329
- if (container && window.innerWidth <= 768) {
331
+ if (container) {
330
332
  const computedStyle = window.getComputedStyle(container);
331
333
  await expect(computedStyle.position).toBe('relative');
332
334
  }
@@ -415,7 +417,9 @@ const PerformanceTestComponent: React.FC = () => {
415
417
  React.useEffect(() => {
416
418
  const startTime = window.performance.now();
417
419
 
418
- // Use requestAnimationFrame for reliable timing after render
420
+ // The demo measures itself to display a number; nothing asserts on it, so a
421
+ // slow frame cannot fail anything.
422
+ // eslint-disable-next-line test-flakiness/no-animation-wait -- demo self-measurement, not an assertion
419
423
  const frameId = window.requestAnimationFrame(() => {
420
424
  const endTime = window.performance.now();
421
425
  setRenderTime(endTime - startTime);
@@ -1,6 +1,6 @@
1
1
  import { Box, Button, Typography } from '@mui/material';
2
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
3
- import { expect,userEvent, within } from 'storybook/test';
3
+ import { expect,userEvent, within, waitFor } from 'storybook/test';
4
4
 
5
5
  import { Spacer } from './Spacer';
6
6
 
@@ -76,12 +76,12 @@ export const KeyboardNavigation: Story = {
76
76
  const secondButton = canvas.getByText('Second Button');
77
77
 
78
78
  // Focus first button
79
- firstButton.focus();
80
- await expect(document.activeElement).toBe(firstButton);
79
+ await userEvent.click(firstButton);
80
+ await waitFor(() => expect(document.activeElement).toBe(firstButton));
81
81
 
82
82
  // Tab to second button (spacer should be skipped)
83
83
  await userEvent.tab();
84
- await expect(document.activeElement).toBe(secondButton);
84
+ await waitFor(() => expect(document.activeElement).toBe(secondButton));
85
85
  });
86
86
 
87
87
  await step('Pass Test', async () => {
@@ -149,12 +149,14 @@ export const FocusManagement: Story = {
149
149
  await step('Verify spacer is not focusable', async () => {
150
150
  const spacer = await canvas.findByTestId('spacer');
151
151
 
152
- // Spacer should not have tabindex
153
- await expect(spacer.getAttribute('tabindex')).toBeNull();
152
+ // A spacer is decoration: it carries no tabindex, so the tab order runs
153
+ // straight past it.
154
+ await expect(spacer).not.toHaveAttribute('tabindex');
154
155
 
155
- // Try to focus spacer - should not work
156
- spacer.focus();
157
- await expect(document.activeElement).not.toBe(spacer);
156
+ // Clicking it is the closest a user can come to trying to focus it, and
157
+ // focus should stay wherever it already was.
158
+ await userEvent.click(spacer);
159
+ await waitFor(() => expect(document.activeElement).not.toBe(spacer));
158
160
  });
159
161
 
160
162
  await step('Pass Test', async () => {
@@ -165,16 +165,16 @@ export const FocusManagement: Story = {
165
165
  const afterBtn = canvas.getByText('After Blockquote');
166
166
 
167
167
  // Start from before button
168
- beforeBtn.focus();
169
- await expect(beforeBtn).toHaveFocus();
168
+ await userEvent.click(beforeBtn);
169
+ await waitFor(() => expect(beforeBtn).toHaveFocus());
170
170
 
171
171
  // Tab to blockquote
172
172
  await userEvent.tab();
173
- await expect(blockquote).toHaveFocus();
173
+ await waitFor(() => expect(blockquote).toHaveFocus());
174
174
 
175
175
  // Tab to after button
176
176
  await userEvent.tab();
177
- await expect(afterBtn).toHaveFocus();
177
+ await waitFor(() => expect(afterBtn).toHaveFocus());
178
178
  },
179
179
  };
180
180
 
@@ -312,10 +312,10 @@ export const Performance: Story = {
312
312
 
313
313
  // Check that all items rendered
314
314
  for (let i = 1; i <= 10; i++) {
315
+ // Matched on the part that identifies the instance rather than the whole
316
+ // sentence, which would break on any wording change.
315
317
  await expect(
316
- canvas.getByText(
317
- `Performance test blockquote number ${i}. This tests rendering performance with multiple instances.`,
318
- ),
318
+ canvas.getByText(new RegExp(`Performance test blockquote number ${i}\\.`)),
319
319
  ).toBeInTheDocument();
320
320
  await expect(canvas.getByText(`— Author ${i}`)).toBeInTheDocument();
321
321
  }
@@ -0,0 +1,101 @@
1
+ import type { CSSObject, Theme } from '@mui/material';
2
+
3
+ import type { HeadingProps } from './Heading.types';
4
+
5
+ type Level = NonNullable<HeadingProps['level']>;
6
+ type Weight = NonNullable<HeadingProps['weight']>;
7
+
8
+ const getColorFromTheme = (theme: Theme, color: string): string => {
9
+ if (color === 'neutral') {
10
+ return theme.palette.text.primary;
11
+ }
12
+
13
+ const colorMap: Record<string, string> = {
14
+ primary: theme.palette.primary.main,
15
+ secondary: theme.palette.secondary.main,
16
+ success: theme.palette.success.main,
17
+ warning: theme.palette.warning.main,
18
+ danger: theme.palette.error.main,
19
+ };
20
+
21
+ return colorMap[color] || theme.palette.text.primary;
22
+ };
23
+
24
+ const WEIGHTS: Record<Weight, number> = {
25
+ light: 300,
26
+ normal: 400,
27
+ medium: 500,
28
+ semibold: 600,
29
+ bold: 700,
30
+ };
31
+
32
+ /**
33
+ * Each level's metrics, plus the weight it uses when the caller asks for
34
+ * `normal`. A heading's "normal" is not body text's 400 — it is the weight that
35
+ * level is meant to carry, which gets heavier as the level gets larger.
36
+ */
37
+ const LEVELS: Record<Level, CSSObject & { normalWeight: number }> = {
38
+ display: { fontSize: '4rem', lineHeight: 0.95, letterSpacing: '-0.03em', normalWeight: 800 },
39
+ h1: { fontSize: '3rem', lineHeight: 1.1, letterSpacing: '-0.02em', normalWeight: 700 },
40
+ h2: { fontSize: '2.5rem', lineHeight: 1.2, letterSpacing: '-0.015em', normalWeight: 700 },
41
+ h3: { fontSize: '2rem', lineHeight: 1.25, letterSpacing: '-0.01em', normalWeight: 600 },
42
+ h4: { fontSize: '1.5rem', lineHeight: 1.3, letterSpacing: '-0.005em', normalWeight: 600 },
43
+ h5: { fontSize: '1.25rem', lineHeight: 1.4, normalWeight: 600 },
44
+ h6: { fontSize: '1.125rem', lineHeight: 1.4, normalWeight: 600 },
45
+ };
46
+
47
+ /** The two-stop gradient each colour paints its glyphs with. */
48
+ const PRIMARY_STOPS = (theme: Theme): [string, string] => [
49
+ theme.palette.primary.main,
50
+ theme.palette.secondary.main,
51
+ ];
52
+
53
+ const GRADIENT_STOPS: Record<string, (theme: Theme) => [string, string]> = {
54
+ primary: PRIMARY_STOPS,
55
+ secondary: (theme) => [theme.palette.secondary.main, theme.palette.primary.main],
56
+ success: (theme) => [theme.palette.success.light, theme.palette.success.dark],
57
+ warning: (theme) => [theme.palette.warning.light, theme.palette.warning.dark],
58
+ danger: (theme) => [theme.palette.error.light, theme.palette.error.dark],
59
+ };
60
+
61
+ const gradientFor = (theme: Theme, color: string) => {
62
+ const [from, to] = (GRADIENT_STOPS[color] ?? PRIMARY_STOPS)(theme);
63
+ return `linear-gradient(135deg, ${from} 0%, ${to} 100%)`;
64
+ };
65
+
66
+ export interface HeadingFlags {
67
+ customLevel?: string;
68
+ customColor?: string;
69
+ customWeight?: string;
70
+ gradient?: boolean;
71
+ }
72
+
73
+ export const headingSx = (theme: Theme, flags: HeadingFlags): CSSObject => {
74
+ const { customLevel = 'h2', customColor = 'neutral', customWeight = 'bold', gradient } = flags;
75
+ const { normalWeight, ...metrics } = LEVELS[customLevel as Level] ?? LEVELS.h2;
76
+
77
+ const base: CSSObject = {
78
+ fontFamily: theme.typography.h1.fontFamily,
79
+ margin: 0,
80
+ transition: 'all 0.2s ease',
81
+ ...metrics,
82
+ fontWeight:
83
+ customWeight === 'normal' ? normalWeight : (WEIGHTS[customWeight as Weight] ?? WEIGHTS.bold),
84
+ };
85
+
86
+ if (!gradient) {
87
+ return { ...base, color: getColorFromTheme(theme, customColor) };
88
+ }
89
+
90
+ // The gradient paints the glyphs themselves, so the text must draw no fill of
91
+ // its own for it to show through.
92
+ return {
93
+ ...base,
94
+ background: gradientFor(theme, customColor),
95
+ backgroundClip: 'text',
96
+ WebkitBackgroundClip: 'text',
97
+ WebkitTextFillColor: 'transparent',
98
+ MozBackgroundClip: 'text',
99
+ MozTextFillColor: 'transparent',
100
+ };
101
+ };
@@ -1,177 +1,61 @@
1
- import type { Theme } from '@mui/material';
2
1
  import { styled } from '@mui/material/styles';
3
2
  import React from 'react';
4
3
 
4
+ import { headingSx } from './Heading.styles';
5
+ import type { HeadingFlags } from './Heading.styles';
5
6
  import type { HeadingProps } from './Heading.types';
6
7
 
7
- const getColorFromTheme = (theme: Theme, color: string) => {
8
- if (color === 'neutral') {
9
- return theme.palette.text.primary;
10
- }
11
-
12
- const colorMap: Record<string, string> = {
13
- primary: theme.palette.primary.main,
14
- secondary: theme.palette.secondary.main,
15
- success: theme.palette.success.main,
16
- warning: theme.palette.warning.main,
17
- danger: theme.palette.error.main,
18
- };
19
-
20
- return colorMap[color] || theme.palette.text.primary;
21
- };
22
-
23
- // Create styled versions of each heading element
24
- const createStyledHeading = () =>
8
+ const styledHeading = () =>
25
9
  styled('h1', {
26
10
  shouldForwardProp: (prop) =>
27
11
  !['customLevel', 'customColor', 'customWeight', 'gradient'].includes(prop as string),
28
- })<{
29
- customLevel?: string;
30
- customColor?: string;
31
- customWeight?: string;
32
- gradient?: boolean;
33
- }>(({ theme, customLevel = 'h2', customColor = 'neutral', customWeight = 'bold', gradient }) => {
34
- const textColor = getColorFromTheme(theme, customColor);
35
-
36
- // Weight mapping
37
- const weightMap = {
38
- light: 300,
39
- normal: 400,
40
- medium: 500,
41
- semibold: 600,
42
- bold: 700,
43
- };
44
-
45
- // Level-specific styles
46
- const levelStyles = {
47
- h1: {
48
- fontSize: '3rem',
49
- lineHeight: 1.1,
50
- letterSpacing: '-0.02em',
51
- fontWeight:
52
- customWeight === 'normal' ? 700 : weightMap[customWeight as keyof typeof weightMap],
53
- },
54
- h2: {
55
- fontSize: '2.5rem',
56
- lineHeight: 1.2,
57
- letterSpacing: '-0.015em',
58
- fontWeight:
59
- customWeight === 'normal' ? 700 : weightMap[customWeight as keyof typeof weightMap],
60
- },
61
- h3: {
62
- fontSize: '2rem',
63
- lineHeight: 1.25,
64
- letterSpacing: '-0.01em',
65
- fontWeight:
66
- customWeight === 'normal' ? 600 : weightMap[customWeight as keyof typeof weightMap],
67
- },
68
- h4: {
69
- fontSize: '1.5rem',
70
- lineHeight: 1.3,
71
- letterSpacing: '-0.005em',
72
- fontWeight:
73
- customWeight === 'normal' ? 600 : weightMap[customWeight as keyof typeof weightMap],
74
- },
75
- h5: {
76
- fontSize: '1.25rem',
77
- lineHeight: 1.4,
78
- fontWeight:
79
- customWeight === 'normal' ? 600 : weightMap[customWeight as keyof typeof weightMap],
80
- },
81
- h6: {
82
- fontSize: '1.125rem',
83
- lineHeight: 1.4,
84
- fontWeight:
85
- customWeight === 'normal' ? 600 : weightMap[customWeight as keyof typeof weightMap],
86
- },
87
- display: {
88
- fontSize: '4rem',
89
- lineHeight: 0.95,
90
- letterSpacing: '-0.03em',
91
- fontWeight:
92
- customWeight === 'normal' ? 800 : weightMap[customWeight as keyof typeof weightMap],
93
- },
94
- };
95
-
96
- const baseStyles = {
97
- fontFamily: theme.typography.h1.fontFamily,
98
- margin: 0,
99
- transition: 'all 0.2s ease',
100
- ...levelStyles[customLevel as keyof typeof levelStyles],
101
- };
102
-
103
- // Gradient text effect
104
- if (gradient) {
105
- const gradientColor =
106
- customColor === 'primary'
107
- ? `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.secondary.main} 100%)`
108
- : customColor === 'secondary'
109
- ? `linear-gradient(135deg, ${theme.palette.secondary.main} 0%, ${theme.palette.primary.main} 100%)`
110
- : customColor === 'success'
111
- ? `linear-gradient(135deg, ${theme.palette.success.light} 0%, ${theme.palette.success.dark} 100%)`
112
- : customColor === 'warning'
113
- ? `linear-gradient(135deg, ${theme.palette.warning.light} 0%, ${theme.palette.warning.dark} 100%)`
114
- : customColor === 'danger'
115
- ? `linear-gradient(135deg, ${theme.palette.error.light} 0%, ${theme.palette.error.dark} 100%)`
116
- : `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.secondary.main} 100%)`;
117
-
118
- return {
119
- ...baseStyles,
120
- background: gradientColor,
121
- backgroundClip: 'text',
122
- WebkitBackgroundClip: 'text',
123
- WebkitTextFillColor: 'transparent',
124
- MozBackgroundClip: 'text',
125
- MozTextFillColor: 'transparent',
126
- };
127
- }
128
-
129
- return {
130
- ...baseStyles,
131
- color: textColor,
132
- };
133
- });
134
-
135
- // Create styled components for each heading level
136
- const StyledH1 = createStyledHeading().withComponent('h1');
137
- const StyledH2 = createStyledHeading().withComponent('h2');
138
- const StyledH3 = createStyledHeading().withComponent('h3');
139
- const StyledH4 = createStyledHeading().withComponent('h4');
140
- const StyledH5 = createStyledHeading().withComponent('h5');
141
- const StyledH6 = createStyledHeading().withComponent('h6');
12
+ })<HeadingFlags>(({ theme, ...flags }) => ({ ...headingSx(theme, flags) }));
13
+
14
+ /**
15
+ * One styled component per tag. They share a style function but must be
16
+ * separate components, since `withComponent` fixes the element at definition
17
+ * time — a single component switching tags per render would remount the text.
18
+ */
19
+ const STYLED_BY_TAG = {
20
+ h1: styledHeading().withComponent('h1'),
21
+ h2: styledHeading().withComponent('h2'),
22
+ h3: styledHeading().withComponent('h3'),
23
+ h4: styledHeading().withComponent('h4'),
24
+ h5: styledHeading().withComponent('h5'),
25
+ h6: styledHeading().withComponent('h6'),
26
+ } as const;
27
+
28
+ /**
29
+ * Which tag each level renders. `display` is a size, not a rank, so it uses the
30
+ * h1 element — the document still gets one level-one heading rather than an
31
+ * unnamed tag outside the outline.
32
+ */
33
+ const TAG_FOR_LEVEL: Record<string, keyof typeof STYLED_BY_TAG> = {
34
+ display: 'h1',
35
+ h1: 'h1',
36
+ h2: 'h2',
37
+ h3: 'h3',
38
+ h4: 'h4',
39
+ h5: 'h5',
40
+ h6: 'h6',
41
+ };
142
42
 
143
43
  export const Heading = React.forwardRef<globalThis.HTMLHeadingElement, HeadingProps>(
144
- (
145
- { level = 'h2', color = 'neutral', weight = 'bold', gradient = false, children, ...props },
146
- ref,
147
- ) => {
148
- const commonProps = {
149
- ref,
150
- customLevel: level,
151
- customColor: color,
152
- customWeight: weight,
153
- gradient,
154
- ...props,
155
- };
156
-
157
- // Return the appropriate styled heading component based on level
158
- switch (level) {
159
- case 'h1':
160
- case 'display':
161
- return <StyledH1 {...commonProps}>{children}</StyledH1>;
162
- case 'h2':
163
- return <StyledH2 {...commonProps}>{children}</StyledH2>;
164
- case 'h3':
165
- return <StyledH3 {...commonProps}>{children}</StyledH3>;
166
- case 'h4':
167
- return <StyledH4 {...commonProps}>{children}</StyledH4>;
168
- case 'h5':
169
- return <StyledH5 {...commonProps}>{children}</StyledH5>;
170
- case 'h6':
171
- return <StyledH6 {...commonProps}>{children}</StyledH6>;
172
- default:
173
- return <StyledH2 {...commonProps}>{children}</StyledH2>;
174
- }
44
+ ({ level = 'h2', color = 'neutral', weight = 'bold', gradient = false, children, ...props }, ref) => {
45
+ const Styled = STYLED_BY_TAG[TAG_FOR_LEVEL[level] ?? 'h2'];
46
+
47
+ return (
48
+ <Styled
49
+ ref={ref}
50
+ customLevel={level}
51
+ customColor={color}
52
+ customWeight={weight}
53
+ gradient={gradient}
54
+ {...props}
55
+ >
56
+ {children}
57
+ </Styled>
58
+ );
175
59
  },
176
60
  );
177
61