@khanacademy/wonder-blocks-button 2.9.13 → 2.10.2

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.
@@ -0,0 +1,107 @@
1
+ import {Meta, Story, Canvas} from "@storybook/addon-docs";
2
+ import {StyleSheet} from "aphrodite";
3
+
4
+ import Button from "@khanacademy/wonder-blocks-button";
5
+ import {View} from "@khanacademy/wonder-blocks-core";
6
+
7
+ <Meta
8
+ title="Navigation/Button/Best practices"
9
+ component={Button}
10
+ parameters={{
11
+ previewTabs: {
12
+ canvas: {hidden: true},
13
+ },
14
+ viewMode: "docs",
15
+ chromatic: {
16
+ // Disables chromatic testing for these stories.
17
+ disableSnapshot: true,
18
+ },
19
+ }}
20
+ />
21
+
22
+ ## Best Practices
23
+
24
+ ### Layout
25
+
26
+ In vertical layouts, buttons will stretch horizontally to fill the available
27
+ space. This is probably not what you want unless you're on a very narrow
28
+ screen.
29
+
30
+ <Canvas>
31
+ <Story name="Full-bleed button">
32
+ <View>
33
+ <Button>Label</Button>
34
+ </View>
35
+ </Story>
36
+ </Canvas>
37
+
38
+ This can be corrected by applying appropriate flex styles to the container.
39
+
40
+ <Canvas>
41
+ <Story name="Buttons in rows">
42
+ <View>
43
+ <View style={styles.row}>
44
+ <Button>Button in a row</Button>
45
+ </View>
46
+ <View style={styles.gap} />
47
+ <View style={styles.column}>
48
+ <Button>Button in a column</Button>
49
+ </View>
50
+ </View>
51
+ </Story>
52
+ </Canvas>
53
+
54
+ ### Usign minWidth for internationalization
55
+
56
+ Layouts often specify a specific width of button. When implementing such designs use `minWidth` instead of `width`. `minWidth` allows the button to resize to fit the content whereas `width` does not. This is important for international sites since sometimes strings for UI elements can be much longer in other languages. Both of the buttons below have a "natural" width of `144px`. The one on the right is wider but it accommodates the full string instead of wrapping it.
57
+
58
+ <Canvas>
59
+ <Story name="Using minWidth">
60
+ <View style={styles.row}>
61
+ <Button style={styles.buttonMinWidth} kind="secondary">
62
+ label
63
+ </Button>
64
+ <Button style={styles.buttonMinWidth}>
65
+ label in a different language
66
+ </Button>
67
+ </View>
68
+ </Story>
69
+ </Canvas>
70
+
71
+ ### Truncating text
72
+
73
+ If the parent container of the button doesn't have enough room to accommodate
74
+ the width of the button, the text will truncate. This should ideally never
75
+ happen, but it's sometimes a necessary fallback.
76
+
77
+ <Canvas>
78
+ <Story name="Truncating text">
79
+ <View style={{flexDirection: "row", width: 300}}>
80
+ <Button style={styles.buttonMinWidth} kind="secondary">
81
+ label
82
+ </Button>
83
+ <Button style={styles.buttonMinWidth}>
84
+ label too long for the parent container
85
+ </Button>
86
+ </View>
87
+ </Story>
88
+ </Canvas>
89
+
90
+ export const styles = StyleSheet.create({
91
+ column: {
92
+ alignItems: "flex-start",
93
+ },
94
+ row: {
95
+ flexDirection: "row",
96
+ },
97
+ gap: {
98
+ height: 16,
99
+ },
100
+ button: {
101
+ marginRight: 10,
102
+ },
103
+ buttonMinWidth: {
104
+ marginRight: 10,
105
+ minWidth: 144,
106
+ },
107
+ });
@@ -0,0 +1,231 @@
1
+ // @flow
2
+ import {icons} from "@khanacademy/wonder-blocks-icon";
3
+
4
+ export default {
5
+ children: {
6
+ description: "Text to appear on the button.",
7
+ type: {required: true},
8
+ },
9
+ icon: {
10
+ description: "An icon, displayed to the left of the title.",
11
+ type: {required: false},
12
+ control: {type: "select"},
13
+ options: (Object.keys(icons): Array<string>),
14
+ mapping: icons,
15
+ table: {
16
+ category: "Layout",
17
+ type: {summary: "IconAsset"},
18
+ },
19
+ },
20
+ spinner: {
21
+ description: "If true, replaces the contents with a spinner.",
22
+ control: {type: "boolean"},
23
+ table: {
24
+ category: "Layout",
25
+ type: {
26
+ summary: "boolean",
27
+ detail: "Setting this prop to `true` will disable the button.",
28
+ },
29
+ },
30
+ },
31
+ color: {
32
+ description: "The color of the button, either blue or red.",
33
+ options: ["default", "destructive"],
34
+ control: {type: "radio"},
35
+ table: {
36
+ category: "Theming",
37
+ type: {
38
+ summary: `"default" | "destructive"`,
39
+ },
40
+ },
41
+ },
42
+ kind: {
43
+ description:
44
+ "The kind of the button, either primary, secondary, or tertiary.",
45
+ options: ["primary", "secondary", "tertiary"],
46
+ control: {type: "select"},
47
+ table: {
48
+ type: {summary: "primary | secondary | tertiary"},
49
+ defaultValue: {
50
+ detail: `
51
+ - Primary buttons have background colors.\n- Secondary buttons have a border and no background color.\n- Tertiary buttons have no background or border.
52
+ `,
53
+ },
54
+ },
55
+ },
56
+ light: {
57
+ description: "Whether the button is on a dark/colored background.",
58
+ control: {type: "boolean"},
59
+ table: {
60
+ category: "Theming",
61
+ type: {
62
+ summary: "boolean",
63
+ detail: "Sets primary button background color to white, and secondary and tertiary button title to color.",
64
+ },
65
+ },
66
+ },
67
+ size: {
68
+ description: "The size of the button.",
69
+ options: ["small", "medium", "xlarge"],
70
+ control: {type: "select"},
71
+ table: {
72
+ category: "Layout",
73
+ defaultValue: {
74
+ detail: `"medium" = height: 40; "small" = height: 32; "xlarge" = height: 60;`,
75
+ },
76
+ type: {
77
+ summary: `"medium" | "small" | "xlarge"`,
78
+ },
79
+ },
80
+ },
81
+ disabled: {
82
+ description: "Whether the button is disabled.",
83
+ table: {
84
+ type: {
85
+ summary: "boolean",
86
+ },
87
+ },
88
+ },
89
+ id: {
90
+ description: "An optional id attribute.",
91
+ control: {type: "text"},
92
+ table: {
93
+ type: {
94
+ summary: "string",
95
+ },
96
+ },
97
+ },
98
+ testId: {
99
+ description: "Test ID used for e2e testing.",
100
+ control: {type: "text"},
101
+ table: {
102
+ type: {
103
+ summary: "string",
104
+ },
105
+ },
106
+ },
107
+
108
+ tabIndex: {
109
+ description: "Set the tabindex attribute on the rendered element.",
110
+ control: {type: "number", min: -1},
111
+ table: {
112
+ type: {
113
+ summary: "number",
114
+ },
115
+ },
116
+ },
117
+ style: {
118
+ description: "Optional custom styles.",
119
+ table: {
120
+ category: "Layout",
121
+ type: {
122
+ summary: "StyleType",
123
+ },
124
+ },
125
+ },
126
+ className: {
127
+ description: "Adds CSS classes to the Button.",
128
+ control: {type: "text"},
129
+ table: {
130
+ category: "Layout",
131
+ type: {
132
+ summary: "string",
133
+ },
134
+ },
135
+ },
136
+ /**
137
+ * Events
138
+ */
139
+ onClick: {
140
+ action: "clicked",
141
+ description: `Function to call when button is clicked.
142
+ This callback should be used for things like marking BigBingo conversions. It should NOT be used to redirect to a different URL or to prevent navigation via e.preventDefault(). The event passed to this handler will have its preventDefault() and stopPropagation() methods stubbed out.
143
+ `,
144
+ table: {
145
+ category: "Events",
146
+ type: {
147
+ summary: "(e: SyntheticEvent<>) => mixed",
148
+ detail: `onClick is optional if href is present, but must be defined if
149
+ * href is not`,
150
+ },
151
+ },
152
+ },
153
+ /**
154
+ * Navigation
155
+ */
156
+ skipClientNav: {
157
+ description: `Whether to avoid using client-side navigation. If the URL passed to href is local to the client-side, e.g. /math/algebra/eval-exprs, then it tries to use react-router-dom's Link component which handles the client-side navigation. You can set "skipClientNav" to true avoid using client-side nav entirely.`,
158
+ control: {type: "boolean"},
159
+ table: {
160
+ category: "Navigation",
161
+ type: {
162
+ summary: "Note",
163
+ detail: "All URLs containing a protocol are considered external, e.g. https://khanacademy.org/math/algebra/eval-exprs will trigger a full page reload.",
164
+ },
165
+ },
166
+ },
167
+ rel: {
168
+ description: `Specifies the type of relationship between the current document and the linked document. Should only be used when "href" is specified. This defaults to "noopener noreferrer" when target="_blank", but can be overridden by setting this prop to something else.`,
169
+ control: {type: "text"},
170
+ table: {
171
+ category: "Navigation",
172
+ type: {
173
+ summary: "string",
174
+ },
175
+ },
176
+ },
177
+ target: {
178
+ description: `A target destination window for a link to open in. Should only be used
179
+ * when "href" is specified.`,
180
+ control: {type: "text"},
181
+ table: {
182
+ category: "Navigation",
183
+ type: {
184
+ summary: "string",
185
+ },
186
+ },
187
+ },
188
+ href: {
189
+ description: "URL to navigate to.",
190
+ control: {type: "text"},
191
+ table: {
192
+ category: "Navigation",
193
+ type: {
194
+ summary: "string",
195
+ detail: "URL is required when we use `safeWithNav`",
196
+ },
197
+ },
198
+ },
199
+ beforeNav: {
200
+ description: `Run async code before navigating. If the promise returned rejects then navigation will not occur. If both safeWithNav and beforeNav are provided, beforeNav will be run first and safeWithNav will only be run if beforeNav does not reject.`,
201
+ table: {
202
+ category: "Navigation",
203
+ type: {
204
+ summary: "() => Promise<mixed>",
205
+ },
206
+ },
207
+ },
208
+ safeWithNav: {
209
+ description: `Run async code in the background while client-side navigating. If the browser does a full page load navigation, the callback promise must be settled before the navigation will occur. Errors are ignored so that navigation is guaranteed to succeed.`,
210
+ table: {
211
+ category: "Navigation",
212
+ type: {
213
+ summary: "() => Promise<mixed>",
214
+ },
215
+ },
216
+ },
217
+ /**
218
+ * Accessibility
219
+ */
220
+ ariaLabel: {
221
+ name: "aria-label",
222
+ description: "A label for the button.",
223
+ table: {
224
+ category: "Accessibility",
225
+ type: {
226
+ summary: "string",
227
+ detail: `aria-label should be used when spinner={true} to let people using screen readers that the action taken by clicking the button will take some time to complete.`,
228
+ },
229
+ },
230
+ },
231
+ };
@@ -0,0 +1,68 @@
1
+ import {Meta, Story, Canvas} from "@storybook/addon-docs";
2
+ import {StyleSheet} from "aphrodite";
3
+
4
+ import Button from "@khanacademy/wonder-blocks-button";
5
+ import {View} from "@khanacademy/wonder-blocks-core";
6
+
7
+ <Meta
8
+ title="Navigation/Button/Navigation Callbacks"
9
+ component={Button}
10
+ parameters={{
11
+ previewTabs: {
12
+ canvas: {hidden: true},
13
+ },
14
+ viewMode: "docs",
15
+ chromatic: {
16
+ // Disables chromatic testing for these stories.
17
+ disableSnapshot: true,
18
+ },
19
+ }}
20
+ />
21
+
22
+ ## Running Callbacks on Navigation
23
+
24
+ Sometimes you may need to run some code and also navigate when the user
25
+ clicks the button. For example, you might want to send a request to the
26
+ server and also send the user to a different page. You can do this by
27
+ passing in a URL to the `href` prop and also passing in a callback
28
+ function to either the `onClick`, `beforeNav`, or `safeWithNav` prop.
29
+ Which prop you choose depends on your use case.
30
+
31
+ - `onClick` is guaranteed to run to completion before navigation starts,
32
+ but it is not async aware, so it should only be used if all of the code
33
+ in your callback function executes synchronously.
34
+
35
+ - `beforeNav` is guaranteed to run async operations before navigation
36
+ starts. You must return a promise from the callback function passed in
37
+ to this prop, and the navigation will happen after the promise
38
+ resolves. If the promise rejects, the navigation will not occur.
39
+ This prop should be used if it's important that the async code
40
+ completely finishes before the next URL starts loading.
41
+
42
+ - `safeWithNav` runs async code concurrently with navigation when safe,
43
+ but delays navigation until the async code is finished when
44
+ concurrent execution is not safe. You must return a promise from the
45
+ callback function passed in to this prop, and Wonder Blocks will run
46
+ the async code in parallel with client-side navigation or while opening
47
+ a new tab, but will wait until the async code finishes to start a
48
+ server-side navigation. If the promise rejects the navigation will
49
+ happen anyway. This prop should be used when it's okay to load
50
+ the next URL while the async callback code is running.
51
+
52
+ This table gives an overview of the options:
53
+
54
+ | Prop | Async safe? | Completes before navigation? |
55
+ |-------------|-------------|------------------------------|
56
+ | onClick | no | yes |
57
+ | beforeNav | yes | yes |
58
+ | safeWithNav | yes | no |
59
+
60
+ It is possible to use more than one of these props on the same element.
61
+ If multiple props are used, they will run in this order: first `onClick`,
62
+ then `beforeNav`, then `safeWithNav`. If both `beforeNav` and `safeWithNav`
63
+ are used, the `safeWithNav` callback will not be called until the
64
+ `beforeNav` promise resolves successfully. If the `beforeNav` promise
65
+ rejects, `safeWithNav` will not be run.
66
+
67
+ If the `onClick` handler calls `preventDefault()`, then `beforeNav`
68
+ and `safeWithNav` will still run, but navigation will not occur.
@@ -18,6 +18,17 @@ const keyCodes = {
18
18
  };
19
19
 
20
20
  describe("Button", () => {
21
+ const {location} = window;
22
+
23
+ beforeAll(() => {
24
+ delete window.location;
25
+ window.location = {assign: jest.fn()};
26
+ });
27
+
28
+ afterAll(() => {
29
+ window.location = location;
30
+ });
31
+
21
32
  test("client-side navigation", () => {
22
33
  // Arrange
23
34
  const wrapper = mount(
@@ -2,7 +2,7 @@
2
2
  import * as React from "react";
3
3
  import {StyleSheet} from "aphrodite";
4
4
  import {Link} from "react-router-dom";
5
- import * as PropTypes from "prop-types";
5
+ import {__RouterContext} from "react-router";
6
6
 
7
7
  import {LabelLarge, LabelSmall} from "@khanacademy/wonder-blocks-typography";
8
8
  import Color, {
@@ -30,18 +30,12 @@ type Props = {|
30
30
  type?: "submit",
31
31
  |};
32
32
 
33
- type ContextTypes = {|
34
- router: $FlowFixMe,
35
- |};
36
-
37
33
  const StyledAnchor = addStyle<"a">("a");
38
34
  const StyledButton = addStyle<"button">("button");
39
35
  const StyledLink = addStyle<typeof Link>(Link);
40
36
 
41
37
  export default class ButtonCore extends React.Component<Props> {
42
- static contextTypes: ContextTypes = {router: PropTypes.any};
43
-
44
- render(): React.Node {
38
+ renderInner(router: any): React.Node {
45
39
  const {
46
40
  children,
47
41
  skipClientNav,
@@ -63,7 +57,6 @@ export default class ButtonCore extends React.Component<Props> {
63
57
  waiting: _,
64
58
  ...restProps
65
59
  } = this.props;
66
- const {router} = this.context;
67
60
 
68
61
  const buttonColor =
69
62
  color === "destructive"
@@ -176,6 +169,14 @@ export default class ButtonCore extends React.Component<Props> {
176
169
  );
177
170
  }
178
171
  }
172
+
173
+ render(): React.Node {
174
+ return (
175
+ <__RouterContext.Consumer>
176
+ {(router) => this.renderInner(router)}
177
+ </__RouterContext.Consumer>
178
+ );
179
+ }
179
180
  }
180
181
 
181
182
  const sharedStyles = StyleSheet.create({
@@ -1,6 +1,6 @@
1
1
  // @flow
2
2
  import * as React from "react";
3
- import * as PropTypes from "prop-types";
3
+ import {__RouterContext} from "react-router";
4
4
 
5
5
  import {getClickableBehavior} from "@khanacademy/wonder-blocks-clickable";
6
6
  import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
@@ -139,11 +139,10 @@ export type SharedProps = {|
139
139
  /**
140
140
  * Function to call when button is clicked.
141
141
  *
142
- * This callback should be used for things like marking BigBingo
143
- * conversions. It should NOT be used to redirect to a different URL or to
144
- * prevent navigation via e.preventDefault(). The event passed to this
145
- * handler will have its preventDefault() and stopPropagation() methods
146
- * stubbed out.
142
+ * This callback should be used for running synchronous code, like
143
+ * dispatching a Redux action. For asynchronous code see the
144
+ * beforeNav and safeWithNav props. It should NOT be used to redirect
145
+ * to a different URL.
147
146
  *
148
147
  * Note: onClick is optional if href is present, but must be defined if
149
148
  * href is not
@@ -222,10 +221,6 @@ type Props =
222
221
  safeWithNav: () => Promise<mixed>,
223
222
  |};
224
223
 
225
- type ContextTypes = {|
226
- router: $FlowFixMe,
227
- |};
228
-
229
224
  type DefaultProps = {|
230
225
  color: $PropertyType<Props, "color">,
231
226
  kind: $PropertyType<Props, "kind">,
@@ -243,18 +238,19 @@ type DefaultProps = {|
243
238
  * `ButtonCore` is a stateless component which displays the different states
244
239
  * the `Button` can take.
245
240
  *
246
- * Example usage:
241
+ * ### Usage
242
+ *
247
243
  * ```jsx
244
+ * import Button from "@khanacademy/wonder-blocks-button";
245
+ *
248
246
  * <Button
249
247
  * onClick={(e) => console.log("Hello, world!")}
250
248
  * >
251
- * Label
249
+ * Hello, world!
252
250
  * </Button>
253
251
  * ```
254
252
  */
255
253
  export default class Button extends React.Component<Props> {
256
- static contextTypes: ContextTypes = {router: PropTypes.any};
257
-
258
254
  static defaultProps: DefaultProps = {
259
255
  color: "default",
260
256
  kind: "primary",
@@ -264,7 +260,7 @@ export default class Button extends React.Component<Props> {
264
260
  spinner: false,
265
261
  };
266
262
 
267
- render(): React.Node {
263
+ renderClickableBehavior(router: any): React.Node {
268
264
  const {
269
265
  href = undefined,
270
266
  type = undefined,
@@ -284,7 +280,7 @@ export default class Button extends React.Component<Props> {
284
280
  const ClickableBehavior = getClickableBehavior(
285
281
  href,
286
282
  skipClientNav,
287
- this.context.router,
283
+ router,
288
284
  );
289
285
 
290
286
  const renderProp = (
@@ -344,4 +340,12 @@ export default class Button extends React.Component<Props> {
344
340
  );
345
341
  }
346
342
  }
343
+
344
+ render(): React.Node {
345
+ return (
346
+ <__RouterContext.Consumer>
347
+ {(router) => this.renderClickableBehavior(router)}
348
+ </__RouterContext.Consumer>
349
+ );
350
+ }
347
351
  }