Skip to content

Commit 2f842b1

Browse files
authored
[WB-2178.2] NodeIconButton: Add tokens prop (#2905)
## Summary: Next part of the new `NodeIconButton` component. This PR adds the `tokens` prop to support overriding component-level tokens. The tokens type was updated to be able to define and pass the tokens in a more flexible way. ## Implementation 1. #2897 2. #2905 Issue: WB-2178 ## Test plan: Navigate to `/?path=/docs/packages-iconbutton-nodeiconbutton--docs&globals=theme:thunderblocks#with-custom-tokens` and verify that the tokens are working as expected. Author: jandrade Reviewers: beaesguerra, jandrade Required Reviewers: Approved By: beaesguerra Checks: ✅ 13 checks were successful, ⏭️ 3 checks have been skipped Pull Request URL: #2905
1 parent 59aba89 commit 2f842b1

File tree

5 files changed

+263
-22
lines changed

5 files changed

+263
-22
lines changed

.changeset/afraid-pants-tease.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/wonder-blocks-icon-button": minor
3+
---
4+
5+
NodeIconButton: Adds a new `tokens` prop to support overriding component-level tokens

__docs__/wonder-blocks-icon-button/node-icon-button.stories.tsx

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,13 +195,66 @@ export const WithCustomIcon: StoryComponentType = {
195195
};
196196

197197
/**
198-
* You can use the `styles` prop to apply custom styles to speicific parts of
199-
* the `NodeIconButton` component.
198+
* The recommended way to customize the appearance of the `NodeIconButton`
199+
* component is to use the `tokens` prop. This prop accepts a token object that
200+
* contains the CSS variables that can be overridden to customize the appearance
201+
* of the `NodeIconButton` component.
202+
*
203+
* The following tokens can be overridden:
204+
* - `boxForeground`: The foreground color of the "chonky" box element.
205+
* - `boxBackground`: The background color of the "chonky" box element.
206+
* - `boxShadowColor`: The color of the shadow of the "chonky" box element.
207+
* - `boxPadding`: The padding of the "chonky" box element.
208+
* - `boxShadowYRest`: The y-offset of the rest state shadow of the "chonky" box
209+
* element.
210+
* - `boxShadowYHover`: The y-offset of the hover state shadow of the "chonky"
211+
* box element.
212+
* - `boxShadowYPress`: The y-offset of the press state shadow of the "chonky"
213+
* box element.
214+
* - `iconSize`: The size of the icon element.
215+
*/
216+
export const WithCustomTokens: StoryComponentType = {
217+
render: () => {
218+
return (
219+
<NodeIconButton
220+
icon={IconMappings.info}
221+
aria-label="More information"
222+
tokens={{
223+
boxForeground:
224+
semanticColor.learning.foreground.streaks.default,
225+
boxBackground:
226+
semanticColor.learning.background.streaks.default,
227+
boxShadowColor: semanticColor.learning.math.foreground.pink,
228+
boxPadding: sizing.size_120,
229+
boxShadowYRest: sizing.size_080,
230+
boxShadowYHover: sizing.size_100,
231+
boxShadowYPress: sizing.size_0,
232+
iconSize: sizing.size_960,
233+
}}
234+
/>
235+
);
236+
},
237+
parameters: {
238+
chromatic: {
239+
// Keep snapshots to confirm token overrides are working
240+
disableSnapshot: false,
241+
},
242+
},
243+
};
244+
245+
/**
246+
* Alternatively, you can use the `styles` prop to apply custom styles to
247+
* speicific parts of the `NodeIconButton` component.
200248
*
201249
* The following parts can be styled:
202250
* - `root`: Styles the root element (button)
203251
* - `box`: Styles the "chonky" box element
204252
* - `icon`: Styles the icon element
253+
*
254+
* **Note:** The `styles` prop is not recommended for most use cases. Instead,
255+
* we recommend using the `tokens` prop to customize the appearance of the
256+
* `NodeIconButton` component. If you still need to provide more specific
257+
* styles, you can use the `styles` prop.
205258
*/
206259
export const WithCustomStyles: StoryComponentType = {
207260
render: () => {

packages/wonder-blocks-icon-button/src/components/node-icon-button.tsx

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,20 @@ import {border, semanticColor, sizing} from "@khanacademy/wonder-blocks-tokens";
1010
import type {BaseIconButtonProps} from "../util/icon-button.types";
1111

1212
import {IconButtonUnstyled} from "./icon-button-unstyled";
13+
import {mapTokensToVariables} from "../util/map-tokens-to-variables";
1314

1415
/**
15-
* The object containing the CSS variables that can be overridden to customize
16-
* the appearance of the NodeIconButton component.
16+
* The prefix for the CSS variables used in the NodeIconButton component.
17+
*
18+
* This allows us to avoid collisions with other CSS variables in the
19+
* application.
1720
*/
18-
type Tokens = Partial<{
21+
const VAR_PREFIX = "--wb-c-node-icon-button--";
22+
23+
/**
24+
* The valid component-level tokens for the NodeIconButton component.
25+
*/
26+
type Tokens = {
1927
"--wb-c-node-icon-button--box-foreground": string;
2028
"--wb-c-node-icon-button--box-background": string;
2129
"--wb-c-node-icon-button--box-shadow-color": string;
@@ -24,16 +32,33 @@ type Tokens = Partial<{
2432
"--wb-c-node-icon-button--box-shadow-y-hover": string | number;
2533
"--wb-c-node-icon-button--box-shadow-y-press": string | number;
2634
"--wb-c-node-icon-button--icon-size": string | number;
27-
}>;
35+
};
36+
37+
/**
38+
* The default tokens that are assigned to the root element.
39+
*
40+
* These tokens could be overridden by baked-in variants and/or the `tokens`
41+
* prop.
42+
*/
43+
const DEFAULT_TOKENS: Tokens = {
44+
"--wb-c-node-icon-button--box-padding": sizing.size_100,
45+
"--wb-c-node-icon-button--box-shadow-y-rest": "6px",
46+
"--wb-c-node-icon-button--box-shadow-y-hover": "8px",
47+
"--wb-c-node-icon-button--box-shadow-y-press": sizing.size_0,
48+
"--wb-c-node-icon-button--icon-size": sizing.size_480,
49+
"--wb-c-node-icon-button--box-foreground":
50+
semanticColor.learning.foreground.progress.notStarted.strong,
51+
"--wb-c-node-icon-button--box-background":
52+
semanticColor.learning.background.progress.notStarted.default,
53+
"--wb-c-node-icon-button--box-shadow-color":
54+
semanticColor.learning.shadow.progress.notStarted.default,
55+
};
2856

2957
type Props = Omit<BaseIconButtonProps, "kind" | "style"> & {
3058
/**
3159
* The action type of the button. This determines the visual style of
3260
* the button. Defaults to `notStarted`.
3361
*
34-
* - `notStarted` is used for buttons that indicate a not started action.
35-
* - `attempted` is used for buttons that indicate an attempted (in progress)
36-
* action.
3762
* - `complete` is used for buttons that indicate a complete action.
3863
*/
3964
actionType?: "notStarted" | "attempted" | "complete";
@@ -60,11 +85,26 @@ type Props = Omit<BaseIconButtonProps, "kind" | "style"> & {
6085
box?: StyleType;
6186
icon?: StyleType;
6287
};
88+
89+
/**
90+
* The token object that contains the CSS variables that can be overridden
91+
* to customize the appearance of the NodeIconButton component.
92+
*/
93+
tokens?: {
94+
boxForeground?: string;
95+
boxBackground?: string;
96+
boxShadowColor?: string;
97+
boxPadding?: string | number;
98+
boxShadowYRest?: string | number;
99+
boxShadowYHover?: string | number;
100+
boxShadowYPress?: string | number;
101+
iconSize?: string | number;
102+
};
63103
};
64104

65105
/**
66106
* Node buttons are visual representations of activities along in a Learning
67-
* Path. When a represented No de is a button that launches the activity. Nodes
107+
* Path. When a represented Node is a button that launches the activity. Nodes
68108
* use the Chonky shadow style.
69109
*
70110
* ```tsx
@@ -91,6 +131,7 @@ export const NodeIconButton: React.ForwardRefExoticComponent<
91131
icon,
92132
size = "large",
93133
styles: stylesProp,
134+
tokens,
94135
type = "button",
95136
// labeling
96137
"aria-label": ariaLabel,
@@ -99,14 +140,19 @@ export const NodeIconButton: React.ForwardRefExoticComponent<
99140

100141
const [pressed, setPressed] = React.useState(false);
101142

102-
const buttonStyles = [
103-
styles.button,
104-
disabled && styles.disabled,
105-
!disabled && pressed && styles.pressed,
106-
variants.size[size] as StyleType,
107-
variants.actionType[actionType] as StyleType,
108-
stylesProp?.root,
109-
];
143+
const buttonStyles = React.useMemo(
144+
() => [
145+
styles.button,
146+
disabled && styles.disabled,
147+
!disabled && pressed && styles.pressed,
148+
variants.size[size] as StyleType,
149+
variants.actionType[actionType] as StyleType,
150+
stylesProp?.root,
151+
// Token overrides.
152+
tokens && mapTokensToVariables(tokens, VAR_PREFIX),
153+
],
154+
[actionType, disabled, pressed, size, stylesProp?.root, tokens],
155+
);
110156

111157
const chonkyStyles = [
112158
styles.chonky,
@@ -141,7 +187,7 @@ export const NodeIconButton: React.ForwardRefExoticComponent<
141187
disabled={disabled}
142188
onPress={handlePress}
143189
ref={ref}
144-
style={buttonStyles}
190+
style={buttonStyles as StyleType}
145191
type={type}
146192
aria-label={ariaLabel}
147193
>
@@ -155,9 +201,13 @@ export const NodeIconButton: React.ForwardRefExoticComponent<
155201
);
156202
});
157203

204+
/**
205+
* An object containing all the different combinations of tokens for the
206+
* NodeIconButton component.
207+
*/
158208
const variants: {
159-
size: Record<string, Tokens>;
160-
actionType: Record<string, Tokens>;
209+
size: Record<string, Partial<Tokens>>;
210+
actionType: Record<string, Partial<Tokens>>;
161211
} = {
162212
size: {
163213
// Default size.
@@ -235,6 +285,7 @@ const styles = StyleSheet.create({
235285
alignSelf: "flex-start",
236286
justifySelf: "center",
237287
gap: sizing.size_020,
288+
...DEFAULT_TOKENS,
238289

239290
/**
240291
* States
@@ -268,7 +319,6 @@ const styles = StyleSheet.create({
268319
...chonkyDisabled,
269320
...disabledStatesStyles,
270321
},
271-
// [":is(:active) .chonky" as any]: chonkyDisabled,
272322
},
273323
// Enable keyboard support for press styles.
274324
pressed: {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {
2+
mapTokensToVariables,
3+
type TokensAsJsVariable,
4+
} from "../map-tokens-to-variables";
5+
6+
describe("mapTokensToVariables", () => {
7+
it("maps camelCase tokens to CSS variable keys with a given prefix", () => {
8+
// Arrange
9+
const tokens: Partial<
10+
TokensAsJsVariable<"boxForeground" | "iconSize">
11+
> = {
12+
boxForeground: "#fff",
13+
iconSize: 24,
14+
};
15+
const prefix = "--wb-node-icon-button--";
16+
17+
// Act
18+
const result = mapTokensToVariables(tokens, prefix);
19+
20+
// Assert
21+
expect(result).toEqual({
22+
"--wb-node-icon-button--box-foreground": "#fff",
23+
"--wb-node-icon-button--icon-size": 24,
24+
});
25+
});
26+
27+
it("returns an empty object for empty tokens", () => {
28+
// Arrange
29+
const tokens: Partial<TokensAsJsVariable<"box-background">> = {};
30+
const prefix = "--wb-node-icon-button--";
31+
32+
// Act
33+
const result = mapTokensToVariables(tokens, prefix);
34+
35+
// Assert
36+
expect(result).toEqual({});
37+
});
38+
39+
it("handles boolean and number values correctly", () => {
40+
// Arrange
41+
const tokens: Partial<TokensAsJsVariable<"boxShadow" | "isActive">> = {
42+
boxShadow: 10,
43+
isActive: false,
44+
};
45+
const prefix = "--custom-";
46+
47+
// Act
48+
const result = mapTokensToVariables(tokens, prefix);
49+
50+
// Assert
51+
expect(result).toEqual({
52+
"--custom-box-shadow": 10,
53+
"--custom-is-active": false,
54+
});
55+
});
56+
57+
it("preserves only properties present on the tokens object", () => {
58+
// Arrange
59+
const tokens: Partial<TokensAsJsVariable<"a" | "b" | "c">> = {
60+
a: 1,
61+
};
62+
const prefix = "--x-";
63+
64+
// Act
65+
const result = mapTokensToVariables(tokens, prefix);
66+
67+
// Assert
68+
expect(result).toEqual({
69+
"--x-a": 1,
70+
});
71+
});
72+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
type TokenValue = string | number;
2+
3+
/**
4+
* Converts a kebab-case string to a camelCase string.
5+
*
6+
* @example
7+
* type CamelCaseString = KebabToCamelCase<"box-foreground">;
8+
* // CamelCaseString = "boxForeground";
9+
*/
10+
type KebabToCamelCase<S extends string> = S extends `${infer T}-${infer U}`
11+
? `${T}${Capitalize<KebabToCamelCase<U>>}`
12+
: S;
13+
14+
/**
15+
* The type containing the keys using the camelCase format.
16+
*
17+
* @example
18+
* type TokensAsJsVariable = {
19+
* boxForeground: string;
20+
*/
21+
export type TokensAsJsVariable<K extends string> = {
22+
[key in KebabToCamelCase<K>]: string | number | boolean;
23+
};
24+
25+
/**
26+
* The type containing the CSS variables that can be overridden to customize the
27+
* appearance of a component.
28+
*
29+
* @example
30+
* const tokens: TokensAsVariable<ComponentTokensPrefix> = {
31+
* "--wb-c-node-icon-button--box-foreground": "red",
32+
* };
33+
*/
34+
type TokensAsCssVariable<T extends string, K extends string> = {
35+
[key in `${T}${K}`]: TokenValue;
36+
};
37+
38+
/**
39+
* Maps a object of camel-cased tokens to a object of kebab-cased CSS variables.
40+
*
41+
* @param tokens The object of camel-cased tokens to map.
42+
* @param prefix The prefix to use for the CSS variables.
43+
* @returns The object of kebab-cased CSS variables.
44+
*/
45+
export function mapTokensToVariables<T extends string, P extends string>(
46+
tokens: Partial<TokensAsJsVariable<T>>,
47+
prefix: P,
48+
) {
49+
// Only pass the keys that are included in the object.
50+
return Object.entries(tokens).reduce(
51+
(acc, [key, value]) => {
52+
// Convert camelCase key to kebab-case.
53+
const kebabKey = key.replace(/([A-Z])/g, "-$1").toLowerCase();
54+
55+
acc[`${prefix}${kebabKey}` as keyof TokensAsCssVariable<P, T>] =
56+
value as TokenValue;
57+
return acc;
58+
},
59+
{} as Record<keyof TokensAsCssVariable<P, T>, TokenValue>,
60+
);
61+
}

0 commit comments

Comments
 (0)