@jobber/components-native 0.10.0 → 0.12.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/dist/src/Button/Button.js +78 -0
- package/dist/src/Button/Button.style.js +92 -0
- package/dist/src/Button/components/InternalButtonLoading/InternalButtonLoading.js +36 -0
- package/dist/src/Button/components/InternalButtonLoading/InternalButtonLoading.style.js +4 -0
- package/dist/src/Button/components/InternalButtonLoading/index.js +1 -0
- package/dist/src/Button/index.js +1 -0
- package/dist/src/Button/types.js +1 -0
- package/dist/src/Heading/Heading.js +51 -0
- package/dist/src/Heading/index.js +1 -0
- package/dist/src/index.js +2 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/src/Button/Button.d.ts +71 -0
- package/dist/types/src/Button/Button.style.d.ts +86 -0
- package/dist/types/src/Button/components/InternalButtonLoading/InternalButtonLoading.d.ts +11 -0
- package/dist/types/src/Button/components/InternalButtonLoading/InternalButtonLoading.style.d.ts +4 -0
- package/dist/types/src/Button/components/InternalButtonLoading/index.d.ts +1 -0
- package/dist/types/src/Button/index.d.ts +2 -0
- package/dist/types/src/Button/types.d.ts +3 -0
- package/dist/types/src/Heading/Heading.d.ts +37 -0
- package/dist/types/src/Heading/index.d.ts +2 -0
- package/dist/types/src/index.d.ts +2 -0
- package/package.json +3 -2
- package/src/Button/Button.style.ts +116 -0
- package/src/Button/Button.test.tsx +298 -0
- package/src/Button/Button.tsx +223 -0
- package/src/Button/components/InternalButtonLoading/InternalButtonLoading.style.ts +5 -0
- package/src/Button/components/InternalButtonLoading/InternalButtonLoading.test.tsx +39 -0
- package/src/Button/components/InternalButtonLoading/InternalButtonLoading.tsx +77 -0
- package/src/Button/components/InternalButtonLoading/index.ts +1 -0
- package/src/Button/index.ts +2 -0
- package/src/Button/types.ts +3 -0
- package/src/Heading/Heading.test.tsx +83 -0
- package/src/Heading/Heading.tsx +132 -0
- package/src/Heading/__snapshots__/Heading.test.tsx.snap +270 -0
- package/src/Heading/index.ts +2 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { TouchableHighlight, View } from "react-native";
|
|
3
|
+
import { IconColorNames, IconNames } from "@jobber/design";
|
|
4
|
+
import { XOR } from "ts-xor";
|
|
5
|
+
import { styles } from "./Button.style";
|
|
6
|
+
// eslint-disable-next-line import/no-internal-modules
|
|
7
|
+
import { InternalButtonLoading } from "./components/InternalButtonLoading";
|
|
8
|
+
import { ButtonSize, ButtonType, ButtonVariation } from "./types";
|
|
9
|
+
import { ActionLabel, ActionLabelVariation } from "../ActionLabel";
|
|
10
|
+
import { Icon } from "../Icon";
|
|
11
|
+
import { tokens } from "../utils/design";
|
|
12
|
+
|
|
13
|
+
interface CommonButtonProps {
|
|
14
|
+
/**
|
|
15
|
+
* Press handler
|
|
16
|
+
*/
|
|
17
|
+
readonly onPress?: () => void;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Themes the button to the type of action it performs
|
|
21
|
+
*/
|
|
22
|
+
readonly variation?: ButtonVariation;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sets the visual hierarchy
|
|
26
|
+
*/
|
|
27
|
+
readonly type?: ButtonType;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Defines the size of the button
|
|
31
|
+
*
|
|
32
|
+
* @default "base"
|
|
33
|
+
*/
|
|
34
|
+
readonly size?: ButtonSize;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Will make the button scale to take up all the available height
|
|
38
|
+
*/
|
|
39
|
+
readonly fullHeight?: boolean;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Will make the button scale to take up all of the available width
|
|
43
|
+
*/
|
|
44
|
+
readonly fullWidth?: boolean;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Makes the button un-clickable
|
|
48
|
+
*/
|
|
49
|
+
readonly disabled?: boolean;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Accessibility hint to help users understand what will happen when they press the button
|
|
53
|
+
*/
|
|
54
|
+
readonly accessibilityHint?: string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Changes the button interface to imply loading and prevents the press callback
|
|
58
|
+
*
|
|
59
|
+
* @default false
|
|
60
|
+
*/
|
|
61
|
+
readonly loading?: boolean;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Adds an leading icon beside the label.
|
|
65
|
+
*/
|
|
66
|
+
readonly icon?: IconNames;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Accessibility label for the component. This is required for components that
|
|
70
|
+
* have an `icon` but not a `label`.
|
|
71
|
+
*
|
|
72
|
+
* If the string is the same as the `label` prop, you don't need to add an
|
|
73
|
+
* `accessibilityLabel`. **Don't use this for testing purposes.**
|
|
74
|
+
*/
|
|
75
|
+
readonly accessibilityLabel?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface LabelButton extends CommonButtonProps {
|
|
79
|
+
/**
|
|
80
|
+
* Text to be displayed on the button
|
|
81
|
+
*/
|
|
82
|
+
readonly label: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface IconButton extends CommonButtonProps {
|
|
86
|
+
readonly icon: IconNames;
|
|
87
|
+
readonly accessibilityLabel: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type ButtonProps = XOR<LabelButton, IconButton>;
|
|
91
|
+
export function Button({
|
|
92
|
+
label,
|
|
93
|
+
onPress,
|
|
94
|
+
variation = "work",
|
|
95
|
+
type = "primary",
|
|
96
|
+
fullHeight = false,
|
|
97
|
+
fullWidth = true,
|
|
98
|
+
disabled = false,
|
|
99
|
+
loading = false,
|
|
100
|
+
size = "base",
|
|
101
|
+
accessibilityLabel,
|
|
102
|
+
accessibilityHint,
|
|
103
|
+
icon,
|
|
104
|
+
}: ButtonProps): JSX.Element {
|
|
105
|
+
const buttonStyle = [
|
|
106
|
+
styles.button,
|
|
107
|
+
styles[variation],
|
|
108
|
+
styles[type],
|
|
109
|
+
styles[size],
|
|
110
|
+
disabled && styles.disabled,
|
|
111
|
+
fullHeight && styles.fullHeight,
|
|
112
|
+
fullWidth && styles.reducedPaddingForFullWidth,
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
// attempts to use Pressable caused problems. When a ScrollView contained
|
|
116
|
+
// an InputText that was focused, it required two presses to activate the
|
|
117
|
+
// Pressable. Using a TouchableHighlight made things register correctly
|
|
118
|
+
// in a single press
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<TouchableHighlight
|
|
122
|
+
onPress={onPress}
|
|
123
|
+
testID={accessibilityLabel || label}
|
|
124
|
+
accessibilityLabel={accessibilityLabel || label}
|
|
125
|
+
accessibilityHint={accessibilityHint}
|
|
126
|
+
accessibilityRole="button"
|
|
127
|
+
accessibilityState={{ disabled, busy: loading }}
|
|
128
|
+
disabled={disabled || loading}
|
|
129
|
+
underlayColor={tokens["color-greyBlue--dark"]}
|
|
130
|
+
activeOpacity={tokens["opacity-pressed"]}
|
|
131
|
+
style={[
|
|
132
|
+
styles.touchable,
|
|
133
|
+
fullWidth && styles.fullWidth,
|
|
134
|
+
fullHeight && styles.fullHeight,
|
|
135
|
+
]}
|
|
136
|
+
>
|
|
137
|
+
<View style={buttonStyle}>
|
|
138
|
+
{loading && <InternalButtonLoading variation={variation} type={type} />}
|
|
139
|
+
<View style={getContentStyles(label, icon)}>
|
|
140
|
+
{icon && (
|
|
141
|
+
<View style={styles.iconStyle}>
|
|
142
|
+
<Icon
|
|
143
|
+
name={icon}
|
|
144
|
+
color={getIconColorVariation(variation, type, disabled)}
|
|
145
|
+
/>
|
|
146
|
+
</View>
|
|
147
|
+
)}
|
|
148
|
+
{label && (
|
|
149
|
+
<View style={styles.labelStyle}>
|
|
150
|
+
<ActionLabel
|
|
151
|
+
variation={getActionLabelVariation(variation, type)}
|
|
152
|
+
disabled={disabled}
|
|
153
|
+
align={icon ? "start" : undefined}
|
|
154
|
+
>
|
|
155
|
+
{label}
|
|
156
|
+
</ActionLabel>
|
|
157
|
+
</View>
|
|
158
|
+
)}
|
|
159
|
+
</View>
|
|
160
|
+
</View>
|
|
161
|
+
</TouchableHighlight>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function getActionLabelVariation(
|
|
166
|
+
variation: string,
|
|
167
|
+
type: string,
|
|
168
|
+
): ActionLabelVariation {
|
|
169
|
+
if (type === "primary" && variation !== "cancel") {
|
|
170
|
+
return "onPrimary";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
switch (variation) {
|
|
174
|
+
case "learning":
|
|
175
|
+
return "learning";
|
|
176
|
+
case "destructive":
|
|
177
|
+
return "destructive";
|
|
178
|
+
case "cancel":
|
|
179
|
+
return "subtle";
|
|
180
|
+
default:
|
|
181
|
+
return "interactive";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function getIconColorVariation(
|
|
186
|
+
variation: ButtonVariation,
|
|
187
|
+
type: string,
|
|
188
|
+
disabled: boolean,
|
|
189
|
+
): IconColorNames {
|
|
190
|
+
if (disabled) {
|
|
191
|
+
return "disabled";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (type === "primary" && variation !== "cancel") {
|
|
195
|
+
return "white";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
switch (variation) {
|
|
199
|
+
case "learning":
|
|
200
|
+
return "informative";
|
|
201
|
+
case "destructive":
|
|
202
|
+
return "destructive";
|
|
203
|
+
case "cancel":
|
|
204
|
+
return "interactiveSubtle";
|
|
205
|
+
default:
|
|
206
|
+
return "interactive";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function getContentStyles(
|
|
211
|
+
label: string | undefined,
|
|
212
|
+
icon: IconNames | undefined,
|
|
213
|
+
) {
|
|
214
|
+
if (label && !icon) {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return [
|
|
219
|
+
styles.content,
|
|
220
|
+
icon && !!label && styles.iconPaddingOffset,
|
|
221
|
+
!!label && styles.contentWithLabel,
|
|
222
|
+
];
|
|
223
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cleanup, render } from "@testing-library/react-native";
|
|
3
|
+
import {
|
|
4
|
+
InternalButtonLoading,
|
|
5
|
+
darkPattern,
|
|
6
|
+
lightPattern,
|
|
7
|
+
} from "./InternalButtonLoading";
|
|
8
|
+
import { ButtonType, ButtonVariation } from "../../types";
|
|
9
|
+
|
|
10
|
+
afterEach(cleanup);
|
|
11
|
+
|
|
12
|
+
describe("Loading pattern", () => {
|
|
13
|
+
it.each<[string, ButtonType, ButtonVariation]>([
|
|
14
|
+
[lightPattern, "primary", "work"],
|
|
15
|
+
[lightPattern, "primary", "destructive"],
|
|
16
|
+
[lightPattern, "primary", "learning"],
|
|
17
|
+
[darkPattern, "primary", "cancel"],
|
|
18
|
+
[darkPattern, "secondary", "cancel"],
|
|
19
|
+
[darkPattern, "secondary", "work"],
|
|
20
|
+
[darkPattern, "secondary", "destructive"],
|
|
21
|
+
[darkPattern, "secondary", "learning"],
|
|
22
|
+
[darkPattern, "tertiary", "cancel"],
|
|
23
|
+
[darkPattern, "tertiary", "work"],
|
|
24
|
+
[darkPattern, "tertiary", "destructive"],
|
|
25
|
+
[darkPattern, "tertiary", "learning"],
|
|
26
|
+
])(
|
|
27
|
+
"should render a %s pattern on %s %s combination",
|
|
28
|
+
(pattern, type, variation) => {
|
|
29
|
+
const { getByTestId } = render(
|
|
30
|
+
<InternalButtonLoading type={type} variation={variation} />,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const component = getByTestId("loadingImage");
|
|
34
|
+
expect(component.props.source).toMatchObject({
|
|
35
|
+
uri: expect.stringContaining(pattern),
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ImageBackground, PixelRatio } from "react-native";
|
|
3
|
+
import Animated, {
|
|
4
|
+
Easing,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
withDelay,
|
|
8
|
+
withRepeat,
|
|
9
|
+
withTiming,
|
|
10
|
+
} from "react-native-reanimated";
|
|
11
|
+
import { styles } from "./InternalButtonLoading.style";
|
|
12
|
+
import { tokens } from "../../../utils/design";
|
|
13
|
+
import { ButtonType, ButtonVariation } from "../../types";
|
|
14
|
+
|
|
15
|
+
interface InternalButtonLoadingProps {
|
|
16
|
+
readonly variation: ButtonVariation;
|
|
17
|
+
readonly type: ButtonType;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const imageWidth = 96;
|
|
21
|
+
const offset = PixelRatio.roundToNearestPixel(imageWidth / PixelRatio.get());
|
|
22
|
+
const leftOffset = -1 * offset;
|
|
23
|
+
|
|
24
|
+
const AnimatedImage = Animated.createAnimatedComponent(ImageBackground);
|
|
25
|
+
|
|
26
|
+
function InternalButtonLoadingInternal({
|
|
27
|
+
variation,
|
|
28
|
+
type,
|
|
29
|
+
}: InternalButtonLoadingProps): JSX.Element {
|
|
30
|
+
const translateX = useSharedValue(0);
|
|
31
|
+
translateX.value = withRepeat(
|
|
32
|
+
withTiming(offset, {
|
|
33
|
+
duration: tokens["timing-loading"],
|
|
34
|
+
easing: Easing.linear,
|
|
35
|
+
}),
|
|
36
|
+
-1,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const opacity = useSharedValue(0);
|
|
40
|
+
opacity.value = withDelay(
|
|
41
|
+
tokens["timing-quick"],
|
|
42
|
+
withTiming(1, {
|
|
43
|
+
duration: tokens["timing-base"],
|
|
44
|
+
easing: Easing.linear,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const animations = useAnimatedStyle(() => ({
|
|
49
|
+
opacity: opacity.value,
|
|
50
|
+
transform: [{ translateX: translateX.value }],
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<AnimatedImage
|
|
55
|
+
testID="loadingImage"
|
|
56
|
+
source={{ uri: getLoadingPattern({ variation, type }) }}
|
|
57
|
+
resizeMode="repeat"
|
|
58
|
+
style={[styles.image, { left: leftOffset }, animations]}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const darkPattern =
|
|
64
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgAgMAAACf9p+rAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAJcEhZcwAAITgAACE4AUWWMWAAAAAMUExURQAAAEdwTAAAAAAAAKDh18UAAAAEdFJOUxkADQwimkzpAAAAtUlEQVRIx+3NqxHDQBRDUc0YuxyXokxgSkmT7sdgP++3YoYrqAsOYDto+7gfpwtfHy4Xfj7cLvw3sYlNbOINAoI4IIgTgrggiBuCIAThQyB8CIQLkXAhEi5EwoVIWEiEhURYSISFRMyQiRkyMUMmZsjECIUYoRAjFGKEQvRQiR4q0UMleqhECwuihQXRwoJoYUEQgiAEQQiCEAQhCEIQhCAIQRCCIARBCIIQBCEIQhCEIAhB8AEuzZ5wHe17xgAAAABJRU5ErkJggg==";
|
|
65
|
+
export const lightPattern =
|
|
66
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgAgMAAACf9p+rAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAJcEhZcwAAITgAACE4AUWWMWAAAAAJUExURf///0dwTP///0SistEAAAADdFJOU0AAILGCadYAAAC0SURBVEjH7c2pFcNAFENRHTMX4pKUE5hS0oT7NZjlbyNmOIJ64AK2g7aP+3G68PXhcuHnw+3CfxOb2MQm3iAgiAOCOCGIC4K4IQhCED4EwodAuBAJFyLhQiRciISFRFhIhIVEWEjEDJmYIRMzZGKGTIxQiBEKMUIhRihED5XooRI9VKKHSrSwIFpYEC0siBYWBCEIQhCEIAhBEIIgBEEIghAEIQhCEIQgCEEQgiAEQQiCEAQfva6WeBniVLgAAAAASUVORK5CYII=";
|
|
67
|
+
|
|
68
|
+
function getLoadingPattern({
|
|
69
|
+
variation,
|
|
70
|
+
type,
|
|
71
|
+
}: InternalButtonLoadingProps): string {
|
|
72
|
+
if (variation === "cancel") return darkPattern;
|
|
73
|
+
if (type === "primary") return lightPattern;
|
|
74
|
+
return darkPattern;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const InternalButtonLoading = React.memo(InternalButtonLoadingInternal);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { InternalButtonLoading } from "./InternalButtonLoading";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "@testing-library/react-native";
|
|
3
|
+
import { Heading } from "./Heading";
|
|
4
|
+
|
|
5
|
+
describe("when Heading called with text as the only prop", () => {
|
|
6
|
+
it("should match snapshot", () => {
|
|
7
|
+
const view = render(<Heading>Default Heading</Heading>).toJSON();
|
|
8
|
+
|
|
9
|
+
expect(view).toMatchSnapshot();
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("when Heading called with reverseTheme", () => {
|
|
14
|
+
it("should match snapshot", () => {
|
|
15
|
+
const view = render(
|
|
16
|
+
<Heading reverseTheme>Reverse Theme Heading</Heading>,
|
|
17
|
+
).toJSON();
|
|
18
|
+
|
|
19
|
+
expect(view).toMatchSnapshot();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("when Heading called with title variation", () => {
|
|
24
|
+
it("should match snapshot", () => {
|
|
25
|
+
const view = render(
|
|
26
|
+
<Heading level={"title"}>Title Heading</Heading>,
|
|
27
|
+
).toJSON();
|
|
28
|
+
|
|
29
|
+
expect(view).toMatchSnapshot();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("when Heading called with Subtitle variation", () => {
|
|
34
|
+
it("should match snapshot", () => {
|
|
35
|
+
const view = render(
|
|
36
|
+
<Heading level={"subtitle"}>Subtitle</Heading>,
|
|
37
|
+
).toJSON();
|
|
38
|
+
|
|
39
|
+
expect(view).toMatchSnapshot();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("when Heading called with sub-heading variation", () => {
|
|
44
|
+
it("should match snapshot", () => {
|
|
45
|
+
const view = render(
|
|
46
|
+
<Heading level={"subHeading"}>Sub-Heading</Heading>,
|
|
47
|
+
).toJSON();
|
|
48
|
+
|
|
49
|
+
expect(view).toMatchSnapshot();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("when Heading called with sub-heading variation and text-color", () => {
|
|
54
|
+
it("should match snapshot", () => {
|
|
55
|
+
const view = render(
|
|
56
|
+
<Heading level={"subHeading"} variation={"subdued"}>
|
|
57
|
+
Sub-Heading
|
|
58
|
+
</Heading>,
|
|
59
|
+
).toJSON();
|
|
60
|
+
|
|
61
|
+
expect(view).toMatchSnapshot();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("when Heading called with an alignment", () => {
|
|
66
|
+
it("should match snapshot", () => {
|
|
67
|
+
const view = render(
|
|
68
|
+
<Heading align={"end"}>Text Aligned Right</Heading>,
|
|
69
|
+
).toJSON();
|
|
70
|
+
|
|
71
|
+
expect(view).toMatchSnapshot();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("when Heading called with maxLines", () => {
|
|
76
|
+
it("should match snapshot", () => {
|
|
77
|
+
const view = render(
|
|
78
|
+
<Heading maxLines="single">Text Aligned Right</Heading>,
|
|
79
|
+
).toJSON();
|
|
80
|
+
|
|
81
|
+
expect(view).toMatchSnapshot();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
FontFamily,
|
|
4
|
+
TextAlign,
|
|
5
|
+
TextColor,
|
|
6
|
+
TruncateLength,
|
|
7
|
+
Typography,
|
|
8
|
+
TypographyProps,
|
|
9
|
+
} from "../Typography";
|
|
10
|
+
import { tokens } from "../utils/design";
|
|
11
|
+
|
|
12
|
+
type HeadingStyle = Pick<
|
|
13
|
+
TypographyProps<FontFamily>,
|
|
14
|
+
"fontFamily" | "fontWeight" | "size" | "lineHeight" | "color"
|
|
15
|
+
>;
|
|
16
|
+
type HeadingColor = Extract<TextColor, "text" | "subdued" | "heading">;
|
|
17
|
+
export type HeadingLevel = "title" | "subtitle" | "heading" | "subHeading";
|
|
18
|
+
|
|
19
|
+
interface HeadingProps<T extends HeadingLevel>
|
|
20
|
+
extends Pick<TypographyProps<"base">, "selectable"> {
|
|
21
|
+
/**
|
|
22
|
+
* Text to display.
|
|
23
|
+
*/
|
|
24
|
+
readonly children: string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The type of heading, e.g., "Title"
|
|
28
|
+
*/
|
|
29
|
+
readonly level?: T;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The text color of heading
|
|
33
|
+
*/
|
|
34
|
+
readonly variation?: HeadingColor;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Uses the reverse variant of the text color for the heading
|
|
38
|
+
*/
|
|
39
|
+
readonly reverseTheme?: boolean;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Alignment of heading
|
|
43
|
+
*/
|
|
44
|
+
readonly align?: TextAlign;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The maximum amount of lines the text can occupy before being truncated with "...".
|
|
48
|
+
* Uses predefined string values that correspond to a doubling scale for the amount of lines.
|
|
49
|
+
*/
|
|
50
|
+
readonly maxLines?: TruncateLength;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Allow text to be resized based on user's device display scale
|
|
54
|
+
*/
|
|
55
|
+
readonly allowFontScaling?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const maxScaledFontSize: Record<HeadingLevel, number> = {
|
|
59
|
+
subHeading: tokens["typography--fontSize-base"] * 1.375,
|
|
60
|
+
heading: tokens["typography--fontSize-base"] * 1.5,
|
|
61
|
+
subtitle: tokens["typography--fontSize-base"] * 1.5,
|
|
62
|
+
title: tokens["typography--fontSize-base"] * 2.125,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function Heading<T extends HeadingLevel = "heading">({
|
|
66
|
+
children,
|
|
67
|
+
level,
|
|
68
|
+
variation = "heading",
|
|
69
|
+
reverseTheme = false,
|
|
70
|
+
align,
|
|
71
|
+
maxLines = "unlimited",
|
|
72
|
+
allowFontScaling = true,
|
|
73
|
+
selectable,
|
|
74
|
+
}: HeadingProps<T>): JSX.Element {
|
|
75
|
+
const headingStyle = getHeadingStyle(level, variation);
|
|
76
|
+
const accessibilityRole = "header";
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Typography
|
|
80
|
+
{...{
|
|
81
|
+
...headingStyle,
|
|
82
|
+
accessibilityRole,
|
|
83
|
+
reverseTheme,
|
|
84
|
+
align,
|
|
85
|
+
maxLines,
|
|
86
|
+
allowFontScaling,
|
|
87
|
+
}}
|
|
88
|
+
maxFontScaleSize={maxScaledFontSize[level as HeadingLevel]}
|
|
89
|
+
selectable={selectable}
|
|
90
|
+
>
|
|
91
|
+
{children}
|
|
92
|
+
</Typography>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getHeadingStyle(
|
|
97
|
+
level: HeadingLevel = "heading",
|
|
98
|
+
variation: HeadingColor,
|
|
99
|
+
): HeadingStyle {
|
|
100
|
+
const headingLevelToStyle: Record<HeadingLevel, HeadingStyle> = {
|
|
101
|
+
title: {
|
|
102
|
+
fontFamily: "display",
|
|
103
|
+
fontWeight: "black",
|
|
104
|
+
size: "jumbo",
|
|
105
|
+
lineHeight: "jumbo",
|
|
106
|
+
color: variation,
|
|
107
|
+
},
|
|
108
|
+
subtitle: {
|
|
109
|
+
fontFamily: "display",
|
|
110
|
+
fontWeight: "extraBold",
|
|
111
|
+
size: "largest",
|
|
112
|
+
lineHeight: "largest",
|
|
113
|
+
color: variation,
|
|
114
|
+
},
|
|
115
|
+
heading: {
|
|
116
|
+
fontFamily: "display",
|
|
117
|
+
fontWeight: "extraBold",
|
|
118
|
+
size: "larger",
|
|
119
|
+
lineHeight: "large",
|
|
120
|
+
color: variation,
|
|
121
|
+
},
|
|
122
|
+
subHeading: {
|
|
123
|
+
fontFamily: "base",
|
|
124
|
+
fontWeight: "semiBold",
|
|
125
|
+
size: "default",
|
|
126
|
+
lineHeight: "base",
|
|
127
|
+
color: variation,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return headingLevelToStyle[level];
|
|
132
|
+
}
|