@khanacademy/wonder-blocks-clickable 2.1.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,388 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import {StyleSheet} from "aphrodite";
4
+ import * as PropTypes from "prop-types";
5
+ import {Link} from "react-router-dom";
6
+
7
+ import {addStyle} from "@khanacademy/wonder-blocks-core";
8
+ import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
9
+ import Color from "@khanacademy/wonder-blocks-color";
10
+
11
+ import getClickableBehavior from "../util/get-clickable-behavior.js";
12
+ import type {ClickableRole, ClickableState} from "./clickable-behavior.js";
13
+ import {isClientSideUrl} from "../util/is-client-side-url.js";
14
+
15
+ type CommonProps = {|
16
+ /**
17
+ * aria-label should be used when `spinner={true}` to let people using screen
18
+ * readers that the action taken by clicking the button will take some
19
+ * time to complete.
20
+ */
21
+ ...$Rest<AriaProps, {|"aria-disabled": "true" | "false" | void|}>,
22
+
23
+ /**
24
+ * The child of Clickable must be a function which returns the component
25
+ * which should be made Clickable. The function is passed an object with
26
+ * three boolean properties: hovered, focused, and pressed.
27
+ */
28
+ children: (ClickableState) => React.Node,
29
+
30
+ /**
31
+ * An onClick function which Clickable can execute when clicked
32
+ */
33
+ onClick?: (e: SyntheticEvent<>) => mixed,
34
+
35
+ /**
36
+ * Optinal href which Clickable should direct to, uses client-side routing
37
+ * by default if react-router is present
38
+ */
39
+ href?: string,
40
+
41
+ /**
42
+ * Styles to apply to the Clickable compoenent
43
+ */
44
+ style?: StyleType,
45
+
46
+ /**
47
+ * Adds CSS classes to the Clickable.
48
+ */
49
+ className?: string,
50
+
51
+ /**
52
+ * Whether the Clickable is on a dark colored background.
53
+ * Sets the default focus ring color to white, instead of blue.
54
+ * Defaults to false.
55
+ */
56
+ light: boolean,
57
+
58
+ /**
59
+ * Disables or enables the child; defaults to false
60
+ */
61
+ disabled: boolean,
62
+
63
+ /**
64
+ * An optional id attribute.
65
+ */
66
+ id?: string,
67
+
68
+ /**
69
+ * Specifies the type of relationship between the current document and the
70
+ * linked document. Should only be used when `href` is specified. This
71
+ * defaults to "noopener noreferrer" when `target="_blank"`, but can be
72
+ * overridden by setting this prop to something else.
73
+ */
74
+ rel?: string,
75
+
76
+ /**
77
+ * The role of the component, can be a role of type ClickableRole
78
+ */
79
+ role?: ClickableRole,
80
+
81
+ /**
82
+ * Avoids client-side routing in the presence of the href prop
83
+ */
84
+ skipClientNav?: boolean,
85
+
86
+ /**
87
+ * Test ID used for e2e testing.
88
+ */
89
+ testId?: string,
90
+
91
+ /**
92
+ * Respond to raw "keydown" event.
93
+ */
94
+ onKeyDown?: (e: SyntheticKeyboardEvent<>) => mixed,
95
+
96
+ /**
97
+ * Respond to raw "keyup" event.
98
+ */
99
+ onKeyUp?: (e: SyntheticKeyboardEvent<>) => mixed,
100
+
101
+ /**
102
+ * Don't show the default focus ring. This should be used when implementing
103
+ * a custom focus ring within your own component that uses Clickable.
104
+ */
105
+ hideDefaultFocusRing?: boolean,
106
+ |};
107
+
108
+ type Props =
109
+ | {|
110
+ ...CommonProps,
111
+
112
+ /**
113
+ * A target destination window for a link to open in.
114
+ */
115
+ target?: "_blank",
116
+ |}
117
+ | {|
118
+ ...CommonProps,
119
+
120
+ href: string,
121
+
122
+ /**
123
+ * Run async code before navigating. If the promise returned rejects then
124
+ * navigation will not occur.
125
+ *
126
+ * If both safeWithNav and beforeNav are provided, beforeNav will be run
127
+ * first and safeWithNav will only be run if beforeNav does not reject.
128
+ */
129
+ beforeNav: () => Promise<mixed>,
130
+ |}
131
+ | {|
132
+ ...CommonProps,
133
+
134
+ href: string,
135
+
136
+ /**
137
+ * Run async code in the background while client-side navigating. If the
138
+ * browser does a full page load navigation, the callback promise must be
139
+ * settled before the navigation will occur. Errors are ignored so that
140
+ * navigation is guaranteed to succeed.
141
+ */
142
+ safeWithNav: () => Promise<mixed>,
143
+
144
+ /**
145
+ * A target destination window for a link to open in.
146
+ */
147
+ target?: "_blank",
148
+ |}
149
+ | {|
150
+ ...CommonProps,
151
+
152
+ href: string,
153
+
154
+ /**
155
+ * Run async code before navigating. If the promise returned rejects then
156
+ * navigation will not occur.
157
+ *
158
+ * If both safeWithNav and beforeNav are provided, beforeNav will be run
159
+ * first and safeWithNav will only be run if beforeNav does not reject.
160
+ */
161
+ beforeNav: () => Promise<mixed>,
162
+
163
+ /**
164
+ * Run async code in the background while client-side navigating. If the
165
+ * browser does a full page load navigation, the callback promise must be
166
+ * settled before the navigation will occur. Errors are ignored so that
167
+ * navigation is guaranteed to succeed.
168
+ */
169
+ safeWithNav: () => Promise<mixed>,
170
+ |};
171
+
172
+ type ContextTypes = {|
173
+ router: $FlowFixMe,
174
+ |};
175
+
176
+ type DefaultProps = {|
177
+ light: $PropertyType<Props, "light">,
178
+ disabled: $PropertyType<Props, "disabled">,
179
+ "aria-label": $PropertyType<Props, "aria-label">,
180
+ |};
181
+
182
+ const StyledAnchor = addStyle<"a">("a");
183
+ const StyledButton = addStyle<"button">("button");
184
+ const StyledLink = addStyle<typeof Link>(Link);
185
+
186
+ /**
187
+ * A component to turn any custom component into a clickable one.
188
+ *
189
+ * Works by wrapping ClickableBehavior around the child element and styling the
190
+ * child appropriately and encapsulates routing logic which can be customized.
191
+ * Expects a function which returns an element as it's child.
192
+ *
193
+ * Example usage:
194
+ * ```jsx
195
+ * <Clickable onClick={() => alert("You clicked me!")}>
196
+ * {({hovered, focused, pressed}) =>
197
+ * <div
198
+ * style={[
199
+ * hovered && styles.hovered,
200
+ * focused && styles.focused,
201
+ * pressed && styles.pressed,
202
+ * ]}
203
+ * >
204
+ * Click Me!
205
+ * </div>
206
+ * }
207
+ * </Clickable>
208
+ * ```
209
+ */
210
+ export default class Clickable extends React.Component<Props> {
211
+ static contextTypes: ContextTypes = {router: PropTypes.any};
212
+
213
+ static defaultProps: DefaultProps = {
214
+ light: false,
215
+ disabled: false,
216
+ "aria-label": "",
217
+ };
218
+
219
+ getCorrectTag: (
220
+ clickableState: ClickableState,
221
+ commonProps: {[string]: any, ...},
222
+ ) => React.Node = (clickableState, commonProps) => {
223
+ const activeHref = this.props.href && !this.props.disabled;
224
+ const useClient =
225
+ this.context.router &&
226
+ !this.props.skipClientNav &&
227
+ isClientSideUrl(this.props.href || "");
228
+
229
+ // NOTE: checking this.props.href here is redundant, but flow
230
+ // needs it to refine this.props.href to a string.
231
+ if (activeHref && useClient && this.props.href) {
232
+ return (
233
+ <StyledLink
234
+ {...commonProps}
235
+ to={this.props.href}
236
+ role={this.props.role}
237
+ target={this.props.target || undefined}
238
+ aria-disabled={this.props.disabled ? "true" : undefined}
239
+ >
240
+ {this.props.children(clickableState)}
241
+ </StyledLink>
242
+ );
243
+ } else if (activeHref && !useClient) {
244
+ return (
245
+ <StyledAnchor
246
+ {...commonProps}
247
+ href={this.props.href}
248
+ role={this.props.role}
249
+ target={this.props.target || undefined}
250
+ aria-disabled={this.props.disabled ? "true" : undefined}
251
+ >
252
+ {this.props.children(clickableState)}
253
+ </StyledAnchor>
254
+ );
255
+ } else {
256
+ return (
257
+ <StyledButton
258
+ {...commonProps}
259
+ type="button"
260
+ disabled={this.props.disabled}
261
+ >
262
+ {this.props.children(clickableState)}
263
+ </StyledButton>
264
+ );
265
+ }
266
+ };
267
+
268
+ render(): React.Node {
269
+ const {
270
+ href,
271
+ onClick,
272
+ skipClientNav,
273
+ beforeNav = undefined,
274
+ safeWithNav = undefined,
275
+ style,
276
+ target = undefined,
277
+ testId,
278
+ onKeyDown,
279
+ onKeyUp,
280
+ hideDefaultFocusRing,
281
+ light,
282
+ disabled,
283
+ ...restProps
284
+ } = this.props;
285
+ const ClickableBehavior = getClickableBehavior(
286
+ href,
287
+ skipClientNav,
288
+ this.context.router,
289
+ );
290
+
291
+ const getStyle = (state: ClickableState): StyleType => [
292
+ styles.reset,
293
+ styles.link,
294
+ !hideDefaultFocusRing &&
295
+ state.focused &&
296
+ (light ? styles.focusedLight : styles.focused),
297
+ style,
298
+ ];
299
+
300
+ if (beforeNav) {
301
+ return (
302
+ <ClickableBehavior
303
+ href={href}
304
+ onClick={onClick}
305
+ beforeNav={beforeNav}
306
+ safeWithNav={safeWithNav}
307
+ onKeyDown={onKeyDown}
308
+ onKeyUp={onKeyUp}
309
+ disabled={disabled}
310
+ >
311
+ {(state, childrenProps) =>
312
+ this.getCorrectTag(state, {
313
+ ...restProps,
314
+ "data-test-id": testId,
315
+ style: getStyle(state),
316
+ ...childrenProps,
317
+ })
318
+ }
319
+ </ClickableBehavior>
320
+ );
321
+ } else {
322
+ return (
323
+ <ClickableBehavior
324
+ href={href}
325
+ onClick={onClick}
326
+ safeWithNav={safeWithNav}
327
+ onKeyDown={onKeyDown}
328
+ onKeyUp={onKeyUp}
329
+ target={target}
330
+ disabled={disabled}
331
+ >
332
+ {(state, childrenProps) =>
333
+ this.getCorrectTag(state, {
334
+ ...restProps,
335
+ "data-test-id": testId,
336
+ style: getStyle(state),
337
+ ...childrenProps,
338
+ })
339
+ }
340
+ </ClickableBehavior>
341
+ );
342
+ }
343
+ }
344
+ }
345
+
346
+ // Source: https://gist.github.com/MoOx/9137295
347
+ const styles = StyleSheet.create({
348
+ reset: {
349
+ border: "none",
350
+ margin: 0,
351
+ padding: 0,
352
+ width: "auto",
353
+ overflow: "visible",
354
+
355
+ background: "transparent",
356
+ textDecoration: "none",
357
+
358
+ /* inherit font & color from ancestor */
359
+ color: "inherit",
360
+ font: "inherit",
361
+
362
+ boxSizing: "border-box",
363
+ // This removes the 300ms click delay on mobile browsers by indicating that
364
+ // "double-tap to zoom" shouldn't be used on this element.
365
+ touchAction: "manipulation",
366
+ userSelect: "none",
367
+
368
+ // This is usual frowned upon b/c of accessibility. We expect users of Clickable
369
+ // to define their own focus styles.
370
+ outline: "none",
371
+
372
+ /* Normalize `line-height`. Cannot be changed from `normal` in Firefox 4+. */
373
+ lineHeight: "normal",
374
+
375
+ /* Corrects font smoothing for webkit */
376
+ WebkitFontSmoothing: "inherit",
377
+ MozOsxFontSmoothing: "inherit",
378
+ },
379
+ link: {
380
+ cursor: "pointer",
381
+ },
382
+ focused: {
383
+ outline: `solid 2px ${Color.blue}`,
384
+ },
385
+ focusedLight: {
386
+ outline: `solid 2px ${Color.white}`,
387
+ },
388
+ });
@@ -0,0 +1,196 @@
1
+ ### Creating a Clickable component
2
+
3
+ You can make custom components `Clickable` by returning them in a function of the
4
+ `Clickable` child. The eventState parameter the function takes allows access to states
5
+ pressed, hovered and clicked, which you may use to create custom styles.
6
+
7
+ Clickable has a default focus ring style built-in. If you are creating your own
8
+ custom focus ring it should be disabled using by setting `hideDefaultFocusRing={true}`
9
+ in the props passed to `Clickable`.
10
+
11
+ ```jsx
12
+ import {StyleSheet} from "aphrodite";
13
+ import Clickable from "@khanacademy/wonder-blocks-clickable";
14
+ import {View} from "@khanacademy/wonder-blocks-core";
15
+ import Color from "@khanacademy/wonder-blocks-color";
16
+ import {Body} from "@khanacademy/wonder-blocks-typography";
17
+
18
+ const styles = StyleSheet.create({
19
+ hovered: {
20
+ textDecoration: "underline",
21
+ backgroundColor: Color.teal,
22
+ },
23
+ pressed: {
24
+ color: Color.blue,
25
+ },
26
+ focused: {
27
+ outline: `solid 4px ${Color.offBlack64}`,
28
+ },
29
+ });
30
+
31
+ <View>
32
+ <Clickable
33
+ onClick={() => alert("You clicked some text!")}
34
+ hideDefaultFocusRing={true}
35
+ role="tab"
36
+ >
37
+ {
38
+ ({hovered, focused, pressed}) =>
39
+ <View style={[
40
+ hovered && styles.hovered,
41
+ focused && styles.focused,
42
+ pressed && styles.pressed,
43
+ ]}>
44
+ <Body>
45
+ This text is clickable!
46
+ </Body>
47
+ </View>
48
+ }
49
+ </Clickable>
50
+ </View>
51
+ ```
52
+
53
+ Clickable has a `light` prop which changes the default focus ring color to fit a dark background.
54
+
55
+ ```jsx
56
+ import {StyleSheet} from "aphrodite";
57
+ import Clickable from "@khanacademy/wonder-blocks-clickable";
58
+ import {View} from "@khanacademy/wonder-blocks-core";
59
+ import Color from "@khanacademy/wonder-blocks-color";
60
+ import Spacing from "@khanacademy/wonder-blocks-spacing";
61
+ import {Body} from "@khanacademy/wonder-blocks-typography";
62
+
63
+ const styles = StyleSheet.create({
64
+ background: {
65
+ backgroundColor: Color.darkBlue,
66
+ color: Color.white,
67
+ padding: Spacing.small_12,
68
+ },
69
+ hovered: {
70
+ textDecoration: "underline",
71
+ backgroundColor: Color.purple,
72
+ },
73
+ pressed: {
74
+ color: Color.blue,
75
+ },
76
+ });
77
+
78
+ <View style={styles.background}>
79
+ <Clickable
80
+ onClick={() => alert("You clicked some text!")}
81
+ role="tab"
82
+ light={true}
83
+ >
84
+ {
85
+ ({hovered, focused, pressed}) =>
86
+ <View style={[
87
+ hovered && styles.hovered,
88
+ pressed && styles.pressed,
89
+ ]}>
90
+ <Body>
91
+ This text is clickable!
92
+ </Body>
93
+ </View>
94
+ }
95
+ </Clickable>
96
+ </View>
97
+ ```
98
+
99
+ ### Client-Side routing with Clickable
100
+
101
+ If your Clickable component is within a React-Router enviroment, your component will automatically default to client-side routing with the `href` prop is set. This behavior can be toggeled by passing the `skipClientNav` prop. In this example we see two Clickable h1 tags, one which employs client-side routing, and the other uses skipClientNav to avoid this default behavior.
102
+
103
+ ```jsx
104
+ import {StyleSheet} from "aphrodite";
105
+ import Clickable from "@khanacademy/wonder-blocks-clickable";
106
+ import {View} from "@khanacademy/wonder-blocks-core";
107
+ import Spacing from "@khanacademy/wonder-blocks-spacing";
108
+ import {MemoryRouter, Route, Switch} from "react-router-dom";
109
+
110
+ const styles = StyleSheet.create({
111
+ row: {
112
+ flexDirection: "row",
113
+ alignItems: "center",
114
+ },
115
+ h1: {
116
+ marginRight: Spacing.large_24,
117
+ }
118
+ });
119
+
120
+ // NOTE: In actual code you would use BrowserRouter instead
121
+ <MemoryRouter>
122
+ <View style={styles.row}>
123
+ <Clickable href="/foo" style={styles.h1} onClick={() => console.log("I'm still on the same page!")}>
124
+ {
125
+ eventState =>
126
+ <h1>
127
+ Uses Client-side Nav
128
+ </h1>
129
+ }
130
+ </Clickable>
131
+ <Clickable href="/foo" style={styles.h1} skipClientNav>
132
+ {
133
+ eventState =>
134
+ <h1>
135
+ Avoids Client-side Nav
136
+ </h1>
137
+ }
138
+ </Clickable>
139
+ <Switch>
140
+ <Route path="/foo">
141
+ <View id="foo">Hello, world!</View>
142
+ </Route>
143
+ </Switch>
144
+ </View>
145
+ </MemoryRouter>
146
+ ```
147
+
148
+ ### Navigating with the Keyboard
149
+
150
+ Clickable adds support to keyboard navigation. This way, your components are
151
+ accessible and emulate better the browser's behavior.
152
+
153
+ *NOTE:* If you want to navigate to an external URL and/or reload the window, make
154
+ sure to use `href` and `skipClientNav={true}`.
155
+
156
+ ```jsx
157
+ import {StyleSheet} from "aphrodite";
158
+ import Clickable from "@khanacademy/wonder-blocks-clickable";
159
+ import {View} from "@khanacademy/wonder-blocks-core";
160
+ import Color from "@khanacademy/wonder-blocks-color";
161
+ import {Body} from "@khanacademy/wonder-blocks-typography";
162
+
163
+ const styles = StyleSheet.create({
164
+ hovered: {
165
+ textDecoration: "underline",
166
+ backgroundColor: Color.teal,
167
+ },
168
+ pressed: {
169
+ color: Color.blue,
170
+ },
171
+ focused: {
172
+ outline: `solid 4px ${Color.lightBlue}`,
173
+ },
174
+ });
175
+
176
+ <View>
177
+ <Clickable
178
+ href="https://www.khanacademy.org/about/tos"
179
+ skipClientNav={true}
180
+ hideDefaultFocusRing={true}
181
+ >
182
+ {
183
+ ({hovered, focused, pressed}) =>
184
+ <View style={[
185
+ hovered && styles.hovered,
186
+ focused && styles.focused,
187
+ pressed && styles.pressed,
188
+ ]}>
189
+ <Body>
190
+ This text should navigate using the keyboard
191
+ </Body>
192
+ </View>
193
+ }
194
+ </Clickable>
195
+ </View>
196
+ ```
@@ -0,0 +1,129 @@
1
+ // @flow
2
+ import * as React from "react";
3
+
4
+ import {StyleSheet} from "aphrodite";
5
+ import Clickable from "@khanacademy/wonder-blocks-clickable";
6
+ import {View} from "@khanacademy/wonder-blocks-core";
7
+ import Color from "@khanacademy/wonder-blocks-color";
8
+ import {Strut} from "@khanacademy/wonder-blocks-layout";
9
+ import Spacing from "@khanacademy/wonder-blocks-spacing";
10
+ import {Body} from "@khanacademy/wonder-blocks-typography";
11
+
12
+ import type {StoryComponentType} from "@storybook/react";
13
+
14
+ export default {
15
+ title: "Clickable",
16
+ };
17
+
18
+ export const basic: StoryComponentType = () => (
19
+ <View>
20
+ <View style={styles.centerText}>
21
+ <Clickable
22
+ href="https://www.khanacademy.org/about/tos"
23
+ skipClientNav={true}
24
+ >
25
+ {({hovered, focused, pressed}) => (
26
+ <View
27
+ style={[
28
+ hovered && styles.hovered,
29
+ pressed && styles.pressed,
30
+ ]}
31
+ >
32
+ <Body>This text is clickable!</Body>
33
+ </View>
34
+ )}
35
+ </Clickable>
36
+ </View>
37
+ <Strut size={Spacing.xLarge_32} />
38
+ <View style={[styles.centerText, styles.dark]}>
39
+ <Clickable
40
+ href="https://www.khanacademy.org/about/tos"
41
+ skipClientNav={true}
42
+ light={true}
43
+ >
44
+ {({hovered, focused, pressed}) => (
45
+ <View
46
+ style={[
47
+ hovered && styles.hovered,
48
+ pressed && styles.pressed,
49
+ ]}
50
+ >
51
+ <Body>This text is clickable!</Body>
52
+ </View>
53
+ )}
54
+ </Clickable>
55
+ </View>
56
+ </View>
57
+ );
58
+
59
+ export const keyboardNavigation: StoryComponentType = () => (
60
+ <View>
61
+ <Clickable
62
+ href="https://www.khanacademy.org/about/tos"
63
+ skipClientNav={true}
64
+ >
65
+ {({hovered, focused, pressed}) => (
66
+ <View
67
+ style={[
68
+ hovered && styles.hovered,
69
+ focused && styles.focused,
70
+ pressed && styles.pressed,
71
+ ]}
72
+ >
73
+ <Body>This text should navigate using the keyboard</Body>
74
+ </View>
75
+ )}
76
+ </Clickable>
77
+ </View>
78
+ );
79
+
80
+ keyboardNavigation.story = {
81
+ parameters: {
82
+ chromatic: {
83
+ // we don't need screenshots because this story only tests behavior.
84
+ disable: true,
85
+ },
86
+ },
87
+ };
88
+
89
+ export const keyboardNavigationTab: StoryComponentType = () => (
90
+ <View>
91
+ <Clickable role="tab" aria-controls="panel-1" id="tab-1">
92
+ {({hovered, focused, pressed}) => (
93
+ <View
94
+ style={[
95
+ hovered && styles.hovered,
96
+ focused && styles.focused,
97
+ pressed && styles.pressed,
98
+ ]}
99
+ >
100
+ <Body>Open School Info</Body>
101
+ </View>
102
+ )}
103
+ </Clickable>
104
+ <View id="panel-1" role="tabpanel" tabindex="0" aria-labelledby="tab-1">
105
+ This is the information for the school.
106
+ </View>
107
+ </View>
108
+ );
109
+
110
+ const styles = StyleSheet.create({
111
+ hovered: {
112
+ textDecoration: "underline",
113
+ backgroundColor: Color.teal,
114
+ },
115
+ pressed: {
116
+ color: Color.blue,
117
+ },
118
+ focused: {
119
+ outline: `solid 4px ${Color.lightBlue}`,
120
+ },
121
+ centerText: {
122
+ textAlign: "center",
123
+ },
124
+ dark: {
125
+ backgroundColor: Color.darkBlue,
126
+ color: Color.white,
127
+ padding: Spacing.xSmall_8,
128
+ },
129
+ });