@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,646 @@
1
+ // @flow
2
+ import * as React from "react";
3
+
4
+ // NOTE: Potentially add to this as more cases come up.
5
+ export type ClickableRole =
6
+ | "button"
7
+ | "link"
8
+ | "checkbox"
9
+ | "radio"
10
+ | "listbox"
11
+ | "option"
12
+ | "menuitem"
13
+ | "menu"
14
+ | "tab";
15
+
16
+ const getAppropriateTriggersForRole = (role: ?ClickableRole) => {
17
+ switch (role) {
18
+ // Triggers on ENTER, but not SPACE
19
+ case "link":
20
+ return {
21
+ triggerOnEnter: true,
22
+ triggerOnSpace: false,
23
+ };
24
+ // Triggers on SPACE, but not ENTER
25
+ case "checkbox":
26
+ case "radio":
27
+ case "listbox":
28
+ case "option":
29
+ return {
30
+ triggerOnEnter: false,
31
+ triggerOnSpace: true,
32
+ };
33
+ // Triggers on both ENTER and SPACE
34
+ case "button":
35
+ case "menuitem":
36
+ case "menu":
37
+ default:
38
+ return {
39
+ triggerOnEnter: true,
40
+ triggerOnSpace: true,
41
+ };
42
+ }
43
+ };
44
+
45
+ type CommonProps = {|
46
+ /**
47
+ * A function that returns the a React `Element`.
48
+ *
49
+ * The React `Element` returned should take in this component's state
50
+ * (`{hovered, focused, pressed}`) as props.
51
+ */
52
+ children: (
53
+ state: ClickableState,
54
+ childrenProps: ChildrenProps,
55
+ ) => React.Node,
56
+
57
+ /**
58
+ * Whether the component is disabled.
59
+ *
60
+ * If the component is disabled, this component will return handlers
61
+ * that do nothing.
62
+ */
63
+ disabled: boolean,
64
+
65
+ /**
66
+ * A URL.
67
+ *
68
+ * If specified, clicking on the component will navigate to the location
69
+ * provided.
70
+ * For keyboard navigation, the default is that both an enter and space
71
+ * press would also navigate to this location. See the triggerOnEnter and
72
+ * triggerOnSpace props for more details.
73
+ */
74
+ href?: string,
75
+
76
+ /**
77
+ * This should only be used by button.js.
78
+ */
79
+ type?: "submit",
80
+
81
+ /**
82
+ * Specifies the type of relationship between the current document and the
83
+ * linked document. Should only be used when `href` is specified. This
84
+ * defaults to "noopener noreferrer" when `target="_blank"`, but can be
85
+ * overridden by setting this prop to something else.
86
+ */
87
+ rel?: string,
88
+
89
+ skipClientNav?: boolean,
90
+
91
+ /**
92
+ * A function to be executed `onclick`.
93
+ */
94
+ onClick?: (e: SyntheticEvent<>) => mixed,
95
+
96
+ /**
97
+ * Run async code in the background while client-side navigating. If the
98
+ * browser does a full page load navigation, the callback promise must be
99
+ * settled before the navigation will occur. Errors are ignored so that
100
+ * navigation is guaranteed to succeed.
101
+ */
102
+ safeWithNav?: () => Promise<mixed>,
103
+
104
+ /**
105
+ * Passed in by withRouter HOC.
106
+ * @ignore
107
+ */
108
+ history?: any,
109
+
110
+ /**
111
+ * A role that encapsulates how the clickable component should behave, which
112
+ * affects which keyboard actions trigger the component. For example, a
113
+ * component with role="button" should be able to be clicked with both the
114
+ * enter and space keys.
115
+ */
116
+ role?: ClickableRole,
117
+
118
+ /**
119
+ * Respond to raw "keydown" event.
120
+ */
121
+ onKeyDown?: (e: SyntheticKeyboardEvent<>) => mixed,
122
+
123
+ /**
124
+ * Respond to raw "keyup" event.
125
+ */
126
+ onKeyUp?: (e: SyntheticKeyboardEvent<>) => mixed,
127
+ |};
128
+
129
+ export type ClickableState = {|
130
+ /**
131
+ * Whether the component is hovered.
132
+ *
133
+ * See component documentation for more details.
134
+ */
135
+ hovered: boolean,
136
+
137
+ /**
138
+ * Whether the component is hovered.
139
+ *
140
+ * See component documentation for more details.
141
+ */
142
+ focused: boolean,
143
+
144
+ /**
145
+ * Whether the component is hovered.
146
+ *
147
+ * See component documentation for more details.
148
+ */
149
+ pressed: boolean,
150
+
151
+ /**
152
+ * When we're waiting for beforeNav or safeWithNav to complete an async
153
+ * action, this will be true.
154
+ *
155
+ * NOTE: We only wait for safeWithNav to complete when doing a full page
156
+ * load navigation.
157
+ */
158
+ waiting: boolean,
159
+ |};
160
+
161
+ type Props =
162
+ | {|
163
+ ...CommonProps,
164
+
165
+ /**
166
+ * A target destination window for a link to open in. Should only be used
167
+ * when `href` is specified.
168
+ */
169
+ target?: "_blank",
170
+ |}
171
+ | {|
172
+ ...CommonProps,
173
+
174
+ /**
175
+ * Run async code before navigating to the URL passed to `href`. If the
176
+ * promise returned rejects then navigation will not occur.
177
+ *
178
+ * If both safeWithNav and beforeNav are provided, beforeNav will be run
179
+ * first and safeWithNav will only be run if beforeNav does not reject.
180
+ *
181
+ * WARNING: Using this with `target="_blank"` will trigger built-in popup
182
+ * blockers in Firefox and Safari. This is because we do navigation
183
+ * programmatically and `beforeNav` causes a delay which means that the
184
+ * browser can't make a directly link between a user action and the
185
+ * navigation.
186
+ */
187
+ beforeNav?: () => Promise<mixed>,
188
+ |};
189
+
190
+ type DefaultProps = {|
191
+ disabled: $PropertyType<Props, "disabled">,
192
+ |};
193
+
194
+ export type ChildrenProps = {|
195
+ onClick: (e: SyntheticMouseEvent<>) => mixed,
196
+ onMouseEnter: (e: SyntheticMouseEvent<>) => mixed,
197
+ onMouseLeave: () => mixed,
198
+ onMouseDown: () => mixed,
199
+ onMouseUp: (e: SyntheticMouseEvent<>) => mixed,
200
+ onDragStart: (e: SyntheticMouseEvent<>) => mixed,
201
+ onTouchStart: () => mixed,
202
+ onTouchEnd: () => mixed,
203
+ onTouchCancel: () => mixed,
204
+ onKeyDown: (e: SyntheticKeyboardEvent<>) => mixed,
205
+ onKeyUp: (e: SyntheticKeyboardEvent<>) => mixed,
206
+ onFocus: (e: SyntheticFocusEvent<>) => mixed,
207
+ onBlur: (e: SyntheticFocusEvent<>) => mixed,
208
+ tabIndex: number,
209
+ rel?: string,
210
+ |};
211
+
212
+ const disabledHandlers = {
213
+ onClick: () => void 0,
214
+ onMouseEnter: () => void 0,
215
+ onMouseLeave: () => void 0,
216
+ onMouseDown: () => void 0,
217
+ onMouseUp: () => void 0,
218
+ onDragStart: () => void 0,
219
+ onTouchStart: () => void 0,
220
+ onTouchEnd: () => void 0,
221
+ onTouchCancel: () => void 0,
222
+ onKeyDown: () => void 0,
223
+ onKeyUp: () => void 0,
224
+ onFocus: () => void 0,
225
+ onBlur: () => void 0,
226
+ tabIndex: -1,
227
+ };
228
+
229
+ const keyCodes = {
230
+ enter: 13,
231
+ space: 32,
232
+ };
233
+
234
+ const startState: ClickableState = {
235
+ hovered: false,
236
+ focused: false,
237
+ pressed: false,
238
+ waiting: false,
239
+ };
240
+
241
+ /**
242
+ * Add hover, focus, and active status updates to a clickable component.
243
+ *
244
+ * Via mouse:
245
+ *
246
+ * 1. Hover over button -> hover state
247
+ * 2. Mouse down -> active state
248
+ * 3. Mouse up -> default state
249
+ * 4. Press tab -> focus state
250
+ *
251
+ * Via touch:
252
+ *
253
+ * 1. Touch down -> press state
254
+ * 2. Touch up -> default state
255
+ *
256
+ * Via keyboard:
257
+ *
258
+ * 1. Tab to focus -> focus state
259
+ * 2. Keydown (spacebar/enter) -> active state
260
+ * 3. Keyup (spacebar/enter) -> focus state
261
+ *
262
+ * Warning: The event handlers returned (onClick, onMouseEnter, onMouseLeave,
263
+ * onMouseDown, onMouseUp, onDragStart, onTouchStart, onTouchEnd, onTouchCancel, onKeyDown,
264
+ * onKeyUp, onFocus, onBlur, tabIndex) should be passed on to the component
265
+ * that has the ClickableBehavior. You cannot override these handlers without
266
+ * potentially breaking the functionality of ClickableBehavior.
267
+ *
268
+ * There are internal props triggerOnEnter and triggerOnSpace that can be set
269
+ * to false if one of those keys shouldn't count as a click on this component.
270
+ * Be careful about setting those to false -- make certain that the component
271
+ * shouldn't process that key.
272
+ *
273
+ * See [this document](https://docs.google.com/document/d/1DG5Rg2f0cawIL5R8UqnPQpd7pbdObk8OyjO5ryYQmBM/edit#)
274
+ * for a more thorough explanation of expected behaviors and potential cavaets.
275
+ *
276
+ * `ClickableBehavior` accepts a function as `children` which is passed state
277
+ * and an object containing event handlers and some other props. The `children`
278
+ * function should return a clickable React Element of some sort.
279
+ *
280
+ * Example:
281
+ *
282
+ * ```js
283
+ * class MyClickableComponent extends React.Component<Props> {
284
+ * render(): React.Node {
285
+ * const ClickableBehavior = getClickableBehavior();
286
+ * return <ClickableBehavior
287
+ * disabled={this.props.disabled}
288
+ * onClick={this.props.onClick}
289
+ * >
290
+ * {({hovered}, childrenProps) =>
291
+ * <RoundRect
292
+ * textcolor='white'
293
+ * backgroundColor={hovered ? 'red' : 'blue'}}
294
+ * {...childrenProps}
295
+ * >
296
+ * {this.props.children}
297
+ * </RoundRect>
298
+ * }
299
+ * </ClickableBehavior>
300
+ * }
301
+ * }
302
+ * ```
303
+ *
304
+ * This follows a pattern called [Function as Child Components]
305
+ * (https://medium.com/merrickchristensen/function-as-child-components-5f3920a9ace9).
306
+ *
307
+ * WARNING: Do not use this component directly, use getClickableBehavior
308
+ * instead. getClickableBehavior takes three arguments (href, directtNav, and
309
+ * router) and returns either the default ClickableBehavior or a react-router
310
+ * aware version.
311
+ *
312
+ * The react-router aware version is returned if `router` is a react-router-dom
313
+ * router, `skipClientNav` is not `true`, and `href` is an internal URL.
314
+ *
315
+ * The `router` can be accessed via this.context.router from a component
316
+ * rendered as a descendant of a BrowserRouter.
317
+ * See https://reacttraining.com/react-router/web/guides/basic-components.
318
+ */
319
+ export default class ClickableBehavior extends React.Component<
320
+ Props,
321
+ ClickableState,
322
+ > {
323
+ waitingForClick: boolean;
324
+ enterClick: boolean;
325
+ dragging: boolean;
326
+
327
+ static defaultProps: DefaultProps = {
328
+ disabled: false,
329
+ };
330
+
331
+ static getDerivedStateFromProps(
332
+ props: Props,
333
+ state: ClickableState,
334
+ ): ?Partial<ClickableState> {
335
+ // If new props are disabled, reset the hovered/focused/pressed states
336
+ if (props.disabled) {
337
+ return startState;
338
+ } else {
339
+ // Cannot return undefined
340
+ return null;
341
+ }
342
+ }
343
+
344
+ constructor(props: Props) {
345
+ super(props);
346
+
347
+ this.state = startState;
348
+ this.waitingForClick = false;
349
+ this.enterClick = false;
350
+ this.dragging = false;
351
+ }
352
+
353
+ navigateOrReset(shouldNavigate: boolean) {
354
+ if (shouldNavigate) {
355
+ const {
356
+ history,
357
+ href,
358
+ skipClientNav,
359
+ target = undefined,
360
+ } = this.props;
361
+ if (href) {
362
+ if (target === "_blank") {
363
+ window.open(href, "_blank");
364
+ this.setState({waiting: false});
365
+ } else if (history && !skipClientNav) {
366
+ history.push(href);
367
+ this.setState({waiting: false});
368
+ } else {
369
+ window.location.assign(href);
370
+ // We don't bother clearing the waiting state, the full page
371
+ // load navigation will do that for us by loading a new page.
372
+ }
373
+ }
374
+ } else {
375
+ this.setState({waiting: false});
376
+ }
377
+ }
378
+
379
+ handleSafeWithNav(
380
+ safeWithNav: () => Promise<mixed>,
381
+ shouldNavigate: boolean,
382
+ ): Promise<void> {
383
+ const {skipClientNav, history} = this.props;
384
+
385
+ if ((history && !skipClientNav) || this.props.target === "_blank") {
386
+ // client-side nav
387
+ safeWithNav();
388
+
389
+ this.navigateOrReset(shouldNavigate);
390
+
391
+ return Promise.resolve();
392
+ } else {
393
+ if (!this.state.waiting) {
394
+ // We only show the spinner for safeWithNav when doing
395
+ // a full page load navigation since since the spinner is
396
+ // indicating that we're waiting for navigation to occur.
397
+ this.setState({waiting: true});
398
+ }
399
+
400
+ return safeWithNav()
401
+ .then(() => {
402
+ if (!this.state.waiting) {
403
+ // We only show the spinner for safeWithNav when doing
404
+ // a full page load navigation since since the spinner is
405
+ // indicating that we're waiting for navigation to occur.
406
+ this.setState({waiting: true});
407
+ }
408
+ return;
409
+ })
410
+ .catch((error) => {
411
+ // We ignore the error here so that we always
412
+ // navigate when using safeWithNav regardless of
413
+ // whether we're doing a client-side nav or not.
414
+ })
415
+ .finally(() => {
416
+ this.navigateOrReset(shouldNavigate);
417
+ });
418
+ }
419
+ }
420
+
421
+ runCallbackAndMaybeNavigate(e: SyntheticEvent<>): ?Promise<void> {
422
+ const {
423
+ onClick = undefined,
424
+ beforeNav = undefined,
425
+ safeWithNav = undefined,
426
+ href,
427
+ type,
428
+ } = this.props;
429
+ let shouldNavigate = true;
430
+ let canSubmit = true;
431
+
432
+ if (onClick) {
433
+ onClick(e);
434
+ }
435
+
436
+ // If onClick() has called e.preventDefault() then we shouldn't
437
+ // navigate.
438
+ if (e.defaultPrevented) {
439
+ shouldNavigate = false;
440
+ canSubmit = false;
441
+ }
442
+
443
+ e.preventDefault();
444
+
445
+ if (!href && type === "submit" && canSubmit) {
446
+ let target = e.currentTarget;
447
+ while (target) {
448
+ if (target instanceof window.HTMLFormElement) {
449
+ // This event must be marked as cancelable otherwise calling
450
+ // e.preventDefault() on it won't do anything in Firefox.
451
+ // Chrome and Safari allow calling e.preventDefault() on
452
+ // non-cancelable events, but really they shouldn't.
453
+ const event = new window.Event("submit", {
454
+ cancelable: true,
455
+ });
456
+ target.dispatchEvent(event);
457
+ break;
458
+ }
459
+ // All events should be typed as SyntheticEvent<HTMLElement>.
460
+ // Updating all of the places will take some time so I'll do
461
+ // this later
462
+ // $FlowFixMe[prop-missing]
463
+ target = target.parentElement;
464
+ }
465
+ }
466
+
467
+ if (beforeNav) {
468
+ this.setState({waiting: true});
469
+ beforeNav()
470
+ .then(() => {
471
+ if (safeWithNav) {
472
+ return this.handleSafeWithNav(
473
+ safeWithNav,
474
+ shouldNavigate,
475
+ );
476
+ } else {
477
+ return this.navigateOrReset(shouldNavigate);
478
+ }
479
+ })
480
+ .catch(() => {});
481
+ } else if (safeWithNav) {
482
+ return this.handleSafeWithNav(safeWithNav, shouldNavigate);
483
+ } else {
484
+ this.navigateOrReset(shouldNavigate);
485
+ }
486
+ }
487
+
488
+ handleClick: (e: SyntheticMouseEvent<>) => void = (e) => {
489
+ const {
490
+ onClick = undefined,
491
+ beforeNav = undefined,
492
+ safeWithNav = undefined,
493
+ } = this.props;
494
+
495
+ if (this.enterClick) {
496
+ return;
497
+ }
498
+
499
+ if (onClick || beforeNav || safeWithNav) {
500
+ this.waitingForClick = false;
501
+ }
502
+
503
+ this.runCallbackAndMaybeNavigate(e);
504
+ };
505
+
506
+ handleMouseEnter: (e: SyntheticMouseEvent<>) => void = (e) => {
507
+ // When the left button is pressed already, we want it to be pressed
508
+ if (e.buttons === 1) {
509
+ this.dragging = true;
510
+ this.setState({pressed: true});
511
+ } else if (!this.waitingForClick) {
512
+ this.setState({hovered: true});
513
+ }
514
+ };
515
+
516
+ handleMouseLeave: () => void = () => {
517
+ if (!this.waitingForClick) {
518
+ this.dragging = false;
519
+ this.setState({hovered: false, pressed: false, focused: false});
520
+ }
521
+ };
522
+
523
+ handleMouseDown: () => void = () => {
524
+ this.setState({pressed: true});
525
+ };
526
+
527
+ handleMouseUp: (e: SyntheticMouseEvent<>) => void = (e) => {
528
+ if (this.dragging) {
529
+ this.dragging = false;
530
+ this.handleClick(e);
531
+ }
532
+ this.setState({pressed: false, focused: false});
533
+ };
534
+
535
+ handleDragStart: (e: SyntheticMouseEvent<>) => void = (e) => {
536
+ this.dragging = true;
537
+ e.preventDefault();
538
+ };
539
+
540
+ handleTouchStart: () => void = () => {
541
+ this.setState({pressed: true});
542
+ };
543
+
544
+ handleTouchEnd: () => void = () => {
545
+ this.setState({pressed: false});
546
+ this.waitingForClick = true;
547
+ };
548
+
549
+ handleTouchCancel: () => void = () => {
550
+ this.setState({pressed: false});
551
+ this.waitingForClick = true;
552
+ };
553
+
554
+ handleKeyDown: (e: SyntheticKeyboardEvent<>) => void = (e) => {
555
+ const {onKeyDown, role} = this.props;
556
+ if (onKeyDown) {
557
+ onKeyDown(e);
558
+ }
559
+
560
+ const keyCode = e.which || e.keyCode;
561
+ const {triggerOnEnter, triggerOnSpace} = getAppropriateTriggersForRole(
562
+ role,
563
+ );
564
+ if (
565
+ (triggerOnEnter && keyCode === keyCodes.enter) ||
566
+ (triggerOnSpace && keyCode === keyCodes.space)
567
+ ) {
568
+ // This prevents space from scrolling down. It also prevents the
569
+ // space and enter keys from triggering click events. We manually
570
+ // call the supplied onClick and handle potential navigation in
571
+ // handleKeyUp instead.
572
+ e.preventDefault();
573
+ this.setState({pressed: true});
574
+ } else if (!triggerOnEnter && keyCode === keyCodes.enter) {
575
+ // If the component isn't supposed to trigger on enter, we have to
576
+ // keep track of the enter keydown to negate the onClick callback
577
+ this.enterClick = true;
578
+ }
579
+ };
580
+
581
+ handleKeyUp: (e: SyntheticKeyboardEvent<>) => void = (e) => {
582
+ const {onKeyUp, role} = this.props;
583
+ if (onKeyUp) {
584
+ onKeyUp(e);
585
+ }
586
+
587
+ const keyCode = e.which || e.keyCode;
588
+ const {triggerOnEnter, triggerOnSpace} = getAppropriateTriggersForRole(
589
+ role,
590
+ );
591
+ if (
592
+ (triggerOnEnter && keyCode === keyCodes.enter) ||
593
+ (triggerOnSpace && keyCode === keyCodes.space)
594
+ ) {
595
+ this.setState({pressed: false, focused: true});
596
+
597
+ this.runCallbackAndMaybeNavigate(e);
598
+ } else if (!triggerOnEnter && keyCode === keyCodes.enter) {
599
+ this.enterClick = false;
600
+ }
601
+ };
602
+
603
+ handleFocus: (e: SyntheticFocusEvent<>) => void = (e) => {
604
+ this.setState({focused: true});
605
+ };
606
+
607
+ handleBlur: (e: SyntheticFocusEvent<>) => void = (e) => {
608
+ this.setState({focused: false, pressed: false});
609
+ };
610
+
611
+ render(): React.Node {
612
+ const childrenProps: ChildrenProps = this.props.disabled
613
+ ? disabledHandlers
614
+ : {
615
+ onClick: this.handleClick,
616
+ onMouseEnter: this.handleMouseEnter,
617
+ onMouseLeave: this.handleMouseLeave,
618
+ onMouseDown: this.handleMouseDown,
619
+ onMouseUp: this.handleMouseUp,
620
+ onDragStart: this.handleDragStart,
621
+ onTouchStart: this.handleTouchStart,
622
+ onTouchEnd: this.handleTouchEnd,
623
+ onTouchCancel: this.handleTouchCancel,
624
+ onKeyDown: this.handleKeyDown,
625
+ onKeyUp: this.handleKeyUp,
626
+ onFocus: this.handleFocus,
627
+ onBlur: this.handleBlur,
628
+ // We set tabIndex to 0 so that users can tab to clickable
629
+ // things that aren't buttons or anchors.
630
+ tabIndex: 0,
631
+ };
632
+
633
+ // When the link is set to open in a new window, we want to set some
634
+ // `rel` attributes. This is to ensure that the links we're sending folks
635
+ // to can't hijack the existing page. These defaults can be overriden
636
+ // by passing in a different value for the `rel` prop.
637
+ // More info: https://www.jitbit.com/alexblog/256-targetblank---the-most-underestimated-vulnerability-ever/
638
+ childrenProps.rel =
639
+ this.props.rel ||
640
+ (this.props.target === "_blank"
641
+ ? "noopener noreferrer"
642
+ : undefined);
643
+ const {children} = this.props;
644
+ return children && children(this.state, childrenProps);
645
+ }
646
+ }