@12-apps/ui 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eslint.config.js +12 -0
- package/package.json +5 -4
- package/src/components/data-display/AlertDialog/AlertDialog.test.stories.tsx +16 -6
- package/src/components/data-display/Banner/Banner.test.stories.tsx +26 -18
- package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
- package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
- package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
- package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
- package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
- package/src/components/data-display/Carousel/Carousel.test.stories.tsx +15 -5
- package/src/components/data-display/Carousel/Carousel.tsx +84 -732
- package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
- package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
- package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
- package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
- package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
- package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
- package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
- package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
- package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
- package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
- package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
- package/src/components/data-display/Sheet/Sheet.test.stories.tsx +25 -21
- package/src/components/data-display/Sheet/Sheet.tsx +125 -948
- package/src/components/feedback/Dialog/Dialog.test.stories.tsx +37 -28
- package/src/components/feedback/Modal/Modal.styles.ts +126 -0
- package/src/components/feedback/Modal/Modal.test.stories.tsx +5 -5
- package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
- package/src/components/feedback/Modal/Modal.tsx +37 -242
- package/src/components/feedback/StackedModal/StackedModal.test.stories.tsx +9 -11
- package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
- package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
- package/src/components/form/InputOTP/InputOTP.tsx +74 -221
- package/src/components/form/Label/Label.parts.tsx +75 -0
- package/src/components/form/Label/Label.styles.ts +206 -0
- package/src/components/form/Label/Label.test.stories.tsx +20 -14
- package/src/components/form/Label/Label.tsx +73 -325
- package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
- package/src/components/form/Menubar/Menubar.item.tsx +121 -0
- package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
- package/src/components/form/Menubar/Menubar.styles.ts +110 -0
- package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
- package/src/components/form/Menubar/Menubar.tsx +64 -347
- package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
- package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
- package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
- package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
- package/src/components/form/Slider/Slider.animations.ts +40 -0
- package/src/components/form/Slider/Slider.styles.ts +292 -0
- package/src/components/form/Slider/Slider.tsx +106 -357
- package/src/components/form/Switch/Switch.animations.ts +62 -0
- package/src/components/form/Switch/Switch.parts.tsx +213 -0
- package/src/components/form/Switch/Switch.styles.ts +355 -0
- package/src/components/form/Switch/Switch.tsx +87 -566
- package/src/components/form/ToggleGroup/ToggleGroup.test.stories.tsx +26 -29
- package/src/components/layout/Resizable/Resizable.test.stories.tsx +11 -7
- package/src/components/layout/Spacer/Spacer.test.stories.tsx +11 -9
- package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
- package/src/components/typography/Heading/Heading.styles.ts +101 -0
- package/src/components/typography/Heading/Heading.tsx +48 -164
- package/src/components/typography/Text/Text.test.stories.tsx +27 -30
- package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
- package/src/utils/index.ts +1 -0
- package/src/utils/withDefaults.ts +20 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
+
await userEvent.click(likeButton);
|
|
213
217
|
await waitFor(() => {
|
|
214
|
-
expect(
|
|
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(
|
|
224
|
+
expect(starButton).toHaveFocus();
|
|
221
225
|
});
|
|
222
226
|
|
|
223
227
|
// Test focus doesn't get lost after selection
|
|
224
|
-
await userEvent.click(
|
|
228
|
+
await userEvent.click(starButton);
|
|
225
229
|
await waitFor(() => {
|
|
226
|
-
expect(
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
//
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
153
|
-
|
|
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
|
-
//
|
|
156
|
-
|
|
157
|
-
await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|