@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.
- package/LICENSE +21 -0
- package/dist/es/index.js +712 -0
- package/dist/index.js +1056 -0
- package/dist/index.js.flow +2 -0
- package/docs.md +7 -0
- package/package.json +32 -0
- package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +426 -0
- package/src/__tests__/generated-snapshot.test.js +176 -0
- package/src/components/__tests__/clickable-behavior.test.js +1313 -0
- package/src/components/__tests__/clickable.test.js +500 -0
- package/src/components/clickable-behavior.js +646 -0
- package/src/components/clickable.js +388 -0
- package/src/components/clickable.md +196 -0
- package/src/components/clickable.stories.js +129 -0
- package/src/index.js +15 -0
- package/src/util/__tests__/get-clickable-behavior.test.js +105 -0
- package/src/util/__tests__/is-client-side-url.js.test.js +50 -0
- package/src/util/get-clickable-behavior.js +43 -0
- package/src/util/is-client-side-url.js +16 -0
package/dist/es/index.js
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import _objectWithoutPropertiesLoose from '@babel/runtime/helpers/objectWithoutPropertiesLoose';
|
|
2
|
+
import _extends from '@babel/runtime/helpers/extends';
|
|
3
|
+
import { Component, createElement } from 'react';
|
|
4
|
+
import { StyleSheet } from 'aphrodite';
|
|
5
|
+
import { any } from 'prop-types';
|
|
6
|
+
import { withRouter, Link } from 'react-router-dom';
|
|
7
|
+
import { addStyle } from '@khanacademy/wonder-blocks-core';
|
|
8
|
+
import Color from '@khanacademy/wonder-blocks-color';
|
|
9
|
+
|
|
10
|
+
const getAppropriateTriggersForRole = role => {
|
|
11
|
+
switch (role) {
|
|
12
|
+
// Triggers on ENTER, but not SPACE
|
|
13
|
+
case "link":
|
|
14
|
+
return {
|
|
15
|
+
triggerOnEnter: true,
|
|
16
|
+
triggerOnSpace: false
|
|
17
|
+
};
|
|
18
|
+
// Triggers on SPACE, but not ENTER
|
|
19
|
+
|
|
20
|
+
case "checkbox":
|
|
21
|
+
case "radio":
|
|
22
|
+
case "listbox":
|
|
23
|
+
case "option":
|
|
24
|
+
return {
|
|
25
|
+
triggerOnEnter: false,
|
|
26
|
+
triggerOnSpace: true
|
|
27
|
+
};
|
|
28
|
+
// Triggers on both ENTER and SPACE
|
|
29
|
+
|
|
30
|
+
case "button":
|
|
31
|
+
case "menuitem":
|
|
32
|
+
case "menu":
|
|
33
|
+
default:
|
|
34
|
+
return {
|
|
35
|
+
triggerOnEnter: true,
|
|
36
|
+
triggerOnSpace: true
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const disabledHandlers = {
|
|
42
|
+
onClick: () => void 0,
|
|
43
|
+
onMouseEnter: () => void 0,
|
|
44
|
+
onMouseLeave: () => void 0,
|
|
45
|
+
onMouseDown: () => void 0,
|
|
46
|
+
onMouseUp: () => void 0,
|
|
47
|
+
onDragStart: () => void 0,
|
|
48
|
+
onTouchStart: () => void 0,
|
|
49
|
+
onTouchEnd: () => void 0,
|
|
50
|
+
onTouchCancel: () => void 0,
|
|
51
|
+
onKeyDown: () => void 0,
|
|
52
|
+
onKeyUp: () => void 0,
|
|
53
|
+
onFocus: () => void 0,
|
|
54
|
+
onBlur: () => void 0,
|
|
55
|
+
tabIndex: -1
|
|
56
|
+
};
|
|
57
|
+
const keyCodes = {
|
|
58
|
+
enter: 13,
|
|
59
|
+
space: 32
|
|
60
|
+
};
|
|
61
|
+
const startState = {
|
|
62
|
+
hovered: false,
|
|
63
|
+
focused: false,
|
|
64
|
+
pressed: false,
|
|
65
|
+
waiting: false
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Add hover, focus, and active status updates to a clickable component.
|
|
69
|
+
*
|
|
70
|
+
* Via mouse:
|
|
71
|
+
*
|
|
72
|
+
* 1. Hover over button -> hover state
|
|
73
|
+
* 2. Mouse down -> active state
|
|
74
|
+
* 3. Mouse up -> default state
|
|
75
|
+
* 4. Press tab -> focus state
|
|
76
|
+
*
|
|
77
|
+
* Via touch:
|
|
78
|
+
*
|
|
79
|
+
* 1. Touch down -> press state
|
|
80
|
+
* 2. Touch up -> default state
|
|
81
|
+
*
|
|
82
|
+
* Via keyboard:
|
|
83
|
+
*
|
|
84
|
+
* 1. Tab to focus -> focus state
|
|
85
|
+
* 2. Keydown (spacebar/enter) -> active state
|
|
86
|
+
* 3. Keyup (spacebar/enter) -> focus state
|
|
87
|
+
*
|
|
88
|
+
* Warning: The event handlers returned (onClick, onMouseEnter, onMouseLeave,
|
|
89
|
+
* onMouseDown, onMouseUp, onDragStart, onTouchStart, onTouchEnd, onTouchCancel, onKeyDown,
|
|
90
|
+
* onKeyUp, onFocus, onBlur, tabIndex) should be passed on to the component
|
|
91
|
+
* that has the ClickableBehavior. You cannot override these handlers without
|
|
92
|
+
* potentially breaking the functionality of ClickableBehavior.
|
|
93
|
+
*
|
|
94
|
+
* There are internal props triggerOnEnter and triggerOnSpace that can be set
|
|
95
|
+
* to false if one of those keys shouldn't count as a click on this component.
|
|
96
|
+
* Be careful about setting those to false -- make certain that the component
|
|
97
|
+
* shouldn't process that key.
|
|
98
|
+
*
|
|
99
|
+
* See [this document](https://docs.google.com/document/d/1DG5Rg2f0cawIL5R8UqnPQpd7pbdObk8OyjO5ryYQmBM/edit#)
|
|
100
|
+
* for a more thorough explanation of expected behaviors and potential cavaets.
|
|
101
|
+
*
|
|
102
|
+
* `ClickableBehavior` accepts a function as `children` which is passed state
|
|
103
|
+
* and an object containing event handlers and some other props. The `children`
|
|
104
|
+
* function should return a clickable React Element of some sort.
|
|
105
|
+
*
|
|
106
|
+
* Example:
|
|
107
|
+
*
|
|
108
|
+
* ```js
|
|
109
|
+
* class MyClickableComponent extends React.Component<Props> {
|
|
110
|
+
* render(): React.Node {
|
|
111
|
+
* const ClickableBehavior = getClickableBehavior();
|
|
112
|
+
* return <ClickableBehavior
|
|
113
|
+
* disabled={this.props.disabled}
|
|
114
|
+
* onClick={this.props.onClick}
|
|
115
|
+
* >
|
|
116
|
+
* {({hovered}, childrenProps) =>
|
|
117
|
+
* <RoundRect
|
|
118
|
+
* textcolor='white'
|
|
119
|
+
* backgroundColor={hovered ? 'red' : 'blue'}}
|
|
120
|
+
* {...childrenProps}
|
|
121
|
+
* >
|
|
122
|
+
* {this.props.children}
|
|
123
|
+
* </RoundRect>
|
|
124
|
+
* }
|
|
125
|
+
* </ClickableBehavior>
|
|
126
|
+
* }
|
|
127
|
+
* }
|
|
128
|
+
* ```
|
|
129
|
+
*
|
|
130
|
+
* This follows a pattern called [Function as Child Components]
|
|
131
|
+
* (https://medium.com/merrickchristensen/function-as-child-components-5f3920a9ace9).
|
|
132
|
+
*
|
|
133
|
+
* WARNING: Do not use this component directly, use getClickableBehavior
|
|
134
|
+
* instead. getClickableBehavior takes three arguments (href, directtNav, and
|
|
135
|
+
* router) and returns either the default ClickableBehavior or a react-router
|
|
136
|
+
* aware version.
|
|
137
|
+
*
|
|
138
|
+
* The react-router aware version is returned if `router` is a react-router-dom
|
|
139
|
+
* router, `skipClientNav` is not `true`, and `href` is an internal URL.
|
|
140
|
+
*
|
|
141
|
+
* The `router` can be accessed via this.context.router from a component
|
|
142
|
+
* rendered as a descendant of a BrowserRouter.
|
|
143
|
+
* See https://reacttraining.com/react-router/web/guides/basic-components.
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
class ClickableBehavior extends Component {
|
|
147
|
+
static getDerivedStateFromProps(props, state) {
|
|
148
|
+
// If new props are disabled, reset the hovered/focused/pressed states
|
|
149
|
+
if (props.disabled) {
|
|
150
|
+
return startState;
|
|
151
|
+
} else {
|
|
152
|
+
// Cannot return undefined
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
constructor(props) {
|
|
158
|
+
super(props);
|
|
159
|
+
|
|
160
|
+
this.handleClick = e => {
|
|
161
|
+
const {
|
|
162
|
+
onClick = undefined,
|
|
163
|
+
beforeNav = undefined,
|
|
164
|
+
safeWithNav = undefined
|
|
165
|
+
} = this.props;
|
|
166
|
+
|
|
167
|
+
if (this.enterClick) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (onClick || beforeNav || safeWithNav) {
|
|
172
|
+
this.waitingForClick = false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.runCallbackAndMaybeNavigate(e);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
this.handleMouseEnter = e => {
|
|
179
|
+
// When the left button is pressed already, we want it to be pressed
|
|
180
|
+
if (e.buttons === 1) {
|
|
181
|
+
this.dragging = true;
|
|
182
|
+
this.setState({
|
|
183
|
+
pressed: true
|
|
184
|
+
});
|
|
185
|
+
} else if (!this.waitingForClick) {
|
|
186
|
+
this.setState({
|
|
187
|
+
hovered: true
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
this.handleMouseLeave = () => {
|
|
193
|
+
if (!this.waitingForClick) {
|
|
194
|
+
this.dragging = false;
|
|
195
|
+
this.setState({
|
|
196
|
+
hovered: false,
|
|
197
|
+
pressed: false,
|
|
198
|
+
focused: false
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
this.handleMouseDown = () => {
|
|
204
|
+
this.setState({
|
|
205
|
+
pressed: true
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
this.handleMouseUp = e => {
|
|
210
|
+
if (this.dragging) {
|
|
211
|
+
this.dragging = false;
|
|
212
|
+
this.handleClick(e);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this.setState({
|
|
216
|
+
pressed: false,
|
|
217
|
+
focused: false
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
this.handleDragStart = e => {
|
|
222
|
+
this.dragging = true;
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
this.handleTouchStart = () => {
|
|
227
|
+
this.setState({
|
|
228
|
+
pressed: true
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
this.handleTouchEnd = () => {
|
|
233
|
+
this.setState({
|
|
234
|
+
pressed: false
|
|
235
|
+
});
|
|
236
|
+
this.waitingForClick = true;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
this.handleTouchCancel = () => {
|
|
240
|
+
this.setState({
|
|
241
|
+
pressed: false
|
|
242
|
+
});
|
|
243
|
+
this.waitingForClick = true;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
this.handleKeyDown = e => {
|
|
247
|
+
const {
|
|
248
|
+
onKeyDown,
|
|
249
|
+
role
|
|
250
|
+
} = this.props;
|
|
251
|
+
|
|
252
|
+
if (onKeyDown) {
|
|
253
|
+
onKeyDown(e);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const keyCode = e.which || e.keyCode;
|
|
257
|
+
const {
|
|
258
|
+
triggerOnEnter,
|
|
259
|
+
triggerOnSpace
|
|
260
|
+
} = getAppropriateTriggersForRole(role);
|
|
261
|
+
|
|
262
|
+
if (triggerOnEnter && keyCode === keyCodes.enter || triggerOnSpace && keyCode === keyCodes.space) {
|
|
263
|
+
// This prevents space from scrolling down. It also prevents the
|
|
264
|
+
// space and enter keys from triggering click events. We manually
|
|
265
|
+
// call the supplied onClick and handle potential navigation in
|
|
266
|
+
// handleKeyUp instead.
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
this.setState({
|
|
269
|
+
pressed: true
|
|
270
|
+
});
|
|
271
|
+
} else if (!triggerOnEnter && keyCode === keyCodes.enter) {
|
|
272
|
+
// If the component isn't supposed to trigger on enter, we have to
|
|
273
|
+
// keep track of the enter keydown to negate the onClick callback
|
|
274
|
+
this.enterClick = true;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
this.handleKeyUp = e => {
|
|
279
|
+
const {
|
|
280
|
+
onKeyUp,
|
|
281
|
+
role
|
|
282
|
+
} = this.props;
|
|
283
|
+
|
|
284
|
+
if (onKeyUp) {
|
|
285
|
+
onKeyUp(e);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const keyCode = e.which || e.keyCode;
|
|
289
|
+
const {
|
|
290
|
+
triggerOnEnter,
|
|
291
|
+
triggerOnSpace
|
|
292
|
+
} = getAppropriateTriggersForRole(role);
|
|
293
|
+
|
|
294
|
+
if (triggerOnEnter && keyCode === keyCodes.enter || triggerOnSpace && keyCode === keyCodes.space) {
|
|
295
|
+
this.setState({
|
|
296
|
+
pressed: false,
|
|
297
|
+
focused: true
|
|
298
|
+
});
|
|
299
|
+
this.runCallbackAndMaybeNavigate(e);
|
|
300
|
+
} else if (!triggerOnEnter && keyCode === keyCodes.enter) {
|
|
301
|
+
this.enterClick = false;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
this.handleFocus = e => {
|
|
306
|
+
this.setState({
|
|
307
|
+
focused: true
|
|
308
|
+
});
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
this.handleBlur = e => {
|
|
312
|
+
this.setState({
|
|
313
|
+
focused: false,
|
|
314
|
+
pressed: false
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
this.state = startState;
|
|
319
|
+
this.waitingForClick = false;
|
|
320
|
+
this.enterClick = false;
|
|
321
|
+
this.dragging = false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
navigateOrReset(shouldNavigate) {
|
|
325
|
+
if (shouldNavigate) {
|
|
326
|
+
const {
|
|
327
|
+
history,
|
|
328
|
+
href,
|
|
329
|
+
skipClientNav,
|
|
330
|
+
target = undefined
|
|
331
|
+
} = this.props;
|
|
332
|
+
|
|
333
|
+
if (href) {
|
|
334
|
+
if (target === "_blank") {
|
|
335
|
+
window.open(href, "_blank");
|
|
336
|
+
this.setState({
|
|
337
|
+
waiting: false
|
|
338
|
+
});
|
|
339
|
+
} else if (history && !skipClientNav) {
|
|
340
|
+
history.push(href);
|
|
341
|
+
this.setState({
|
|
342
|
+
waiting: false
|
|
343
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
window.location.assign(href); // We don't bother clearing the waiting state, the full page
|
|
346
|
+
// load navigation will do that for us by loading a new page.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
this.setState({
|
|
351
|
+
waiting: false
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
handleSafeWithNav(safeWithNav, shouldNavigate) {
|
|
357
|
+
const {
|
|
358
|
+
skipClientNav,
|
|
359
|
+
history
|
|
360
|
+
} = this.props;
|
|
361
|
+
|
|
362
|
+
if (history && !skipClientNav || this.props.target === "_blank") {
|
|
363
|
+
// client-side nav
|
|
364
|
+
safeWithNav();
|
|
365
|
+
this.navigateOrReset(shouldNavigate);
|
|
366
|
+
return Promise.resolve();
|
|
367
|
+
} else {
|
|
368
|
+
if (!this.state.waiting) {
|
|
369
|
+
// We only show the spinner for safeWithNav when doing
|
|
370
|
+
// a full page load navigation since since the spinner is
|
|
371
|
+
// indicating that we're waiting for navigation to occur.
|
|
372
|
+
this.setState({
|
|
373
|
+
waiting: true
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return safeWithNav().then(() => {
|
|
378
|
+
if (!this.state.waiting) {
|
|
379
|
+
// We only show the spinner for safeWithNav when doing
|
|
380
|
+
// a full page load navigation since since the spinner is
|
|
381
|
+
// indicating that we're waiting for navigation to occur.
|
|
382
|
+
this.setState({
|
|
383
|
+
waiting: true
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return;
|
|
388
|
+
}).catch(error => {// We ignore the error here so that we always
|
|
389
|
+
// navigate when using safeWithNav regardless of
|
|
390
|
+
// whether we're doing a client-side nav or not.
|
|
391
|
+
}).finally(() => {
|
|
392
|
+
this.navigateOrReset(shouldNavigate);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
runCallbackAndMaybeNavigate(e) {
|
|
398
|
+
const {
|
|
399
|
+
onClick = undefined,
|
|
400
|
+
beforeNav = undefined,
|
|
401
|
+
safeWithNav = undefined,
|
|
402
|
+
href,
|
|
403
|
+
type
|
|
404
|
+
} = this.props;
|
|
405
|
+
let shouldNavigate = true;
|
|
406
|
+
let canSubmit = true;
|
|
407
|
+
|
|
408
|
+
if (onClick) {
|
|
409
|
+
onClick(e);
|
|
410
|
+
} // If onClick() has called e.preventDefault() then we shouldn't
|
|
411
|
+
// navigate.
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
if (e.defaultPrevented) {
|
|
415
|
+
shouldNavigate = false;
|
|
416
|
+
canSubmit = false;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
e.preventDefault();
|
|
420
|
+
|
|
421
|
+
if (!href && type === "submit" && canSubmit) {
|
|
422
|
+
let target = e.currentTarget;
|
|
423
|
+
|
|
424
|
+
while (target) {
|
|
425
|
+
if (target instanceof window.HTMLFormElement) {
|
|
426
|
+
// This event must be marked as cancelable otherwise calling
|
|
427
|
+
// e.preventDefault() on it won't do anything in Firefox.
|
|
428
|
+
// Chrome and Safari allow calling e.preventDefault() on
|
|
429
|
+
// non-cancelable events, but really they shouldn't.
|
|
430
|
+
const event = new window.Event("submit", {
|
|
431
|
+
cancelable: true
|
|
432
|
+
});
|
|
433
|
+
target.dispatchEvent(event);
|
|
434
|
+
break;
|
|
435
|
+
} // All events should be typed as SyntheticEvent<HTMLElement>.
|
|
436
|
+
// Updating all of the places will take some time so I'll do
|
|
437
|
+
// this later
|
|
438
|
+
// $FlowFixMe[prop-missing]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
target = target.parentElement;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (beforeNav) {
|
|
446
|
+
this.setState({
|
|
447
|
+
waiting: true
|
|
448
|
+
});
|
|
449
|
+
beforeNav().then(() => {
|
|
450
|
+
if (safeWithNav) {
|
|
451
|
+
return this.handleSafeWithNav(safeWithNav, shouldNavigate);
|
|
452
|
+
} else {
|
|
453
|
+
return this.navigateOrReset(shouldNavigate);
|
|
454
|
+
}
|
|
455
|
+
}).catch(() => {});
|
|
456
|
+
} else if (safeWithNav) {
|
|
457
|
+
return this.handleSafeWithNav(safeWithNav, shouldNavigate);
|
|
458
|
+
} else {
|
|
459
|
+
this.navigateOrReset(shouldNavigate);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
render() {
|
|
464
|
+
const childrenProps = this.props.disabled ? disabledHandlers : {
|
|
465
|
+
onClick: this.handleClick,
|
|
466
|
+
onMouseEnter: this.handleMouseEnter,
|
|
467
|
+
onMouseLeave: this.handleMouseLeave,
|
|
468
|
+
onMouseDown: this.handleMouseDown,
|
|
469
|
+
onMouseUp: this.handleMouseUp,
|
|
470
|
+
onDragStart: this.handleDragStart,
|
|
471
|
+
onTouchStart: this.handleTouchStart,
|
|
472
|
+
onTouchEnd: this.handleTouchEnd,
|
|
473
|
+
onTouchCancel: this.handleTouchCancel,
|
|
474
|
+
onKeyDown: this.handleKeyDown,
|
|
475
|
+
onKeyUp: this.handleKeyUp,
|
|
476
|
+
onFocus: this.handleFocus,
|
|
477
|
+
onBlur: this.handleBlur,
|
|
478
|
+
// We set tabIndex to 0 so that users can tab to clickable
|
|
479
|
+
// things that aren't buttons or anchors.
|
|
480
|
+
tabIndex: 0
|
|
481
|
+
}; // When the link is set to open in a new window, we want to set some
|
|
482
|
+
// `rel` attributes. This is to ensure that the links we're sending folks
|
|
483
|
+
// to can't hijack the existing page. These defaults can be overriden
|
|
484
|
+
// by passing in a different value for the `rel` prop.
|
|
485
|
+
// More info: https://www.jitbit.com/alexblog/256-targetblank---the-most-underestimated-vulnerability-ever/
|
|
486
|
+
|
|
487
|
+
childrenProps.rel = this.props.rel || (this.props.target === "_blank" ? "noopener noreferrer" : undefined);
|
|
488
|
+
const {
|
|
489
|
+
children
|
|
490
|
+
} = this.props;
|
|
491
|
+
return children && children(this.state, childrenProps);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
}
|
|
495
|
+
ClickableBehavior.defaultProps = {
|
|
496
|
+
disabled: false
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Returns:
|
|
501
|
+
* - false for hrefs staring with http://, https://, //.
|
|
502
|
+
* - false for '#', 'javascript:...', 'mailto:...', 'tel:...', etc.
|
|
503
|
+
* - true for all other values, e.g. /foo/bar
|
|
504
|
+
*/
|
|
505
|
+
const isClientSideUrl = href => {
|
|
506
|
+
if (typeof href !== "string") {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return !/^(https?:)?\/\//i.test(href) && !/^([^#]*#[\w-]*|[\w\-.]+:)/.test(href);
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Returns either the default ClickableBehavior or a react-router aware version.
|
|
515
|
+
*
|
|
516
|
+
* The react-router aware version is returned if `router` is a react-router-dom
|
|
517
|
+
* router, `skipClientNav` is not `true`, and `href` is an internal URL.
|
|
518
|
+
*
|
|
519
|
+
* The `router` can be accessed via this.context.router from a component rendered
|
|
520
|
+
* as a descendant of a BrowserRouter.
|
|
521
|
+
* See https://reacttraining.com/react-router/web/guides/basic-components.
|
|
522
|
+
*/
|
|
523
|
+
const ClickableBehaviorWithRouter = withRouter(ClickableBehavior);
|
|
524
|
+
function getClickableBehavior(
|
|
525
|
+
/**
|
|
526
|
+
* The URL to navigate to.
|
|
527
|
+
*/
|
|
528
|
+
href,
|
|
529
|
+
/**
|
|
530
|
+
* Should we skip using the react router and go to the page directly.
|
|
531
|
+
*/
|
|
532
|
+
skipClientNav,
|
|
533
|
+
/**
|
|
534
|
+
* router object added to the React context object by react-router-dom.
|
|
535
|
+
*/
|
|
536
|
+
router) {
|
|
537
|
+
if (router && skipClientNav !== true && href && isClientSideUrl(href)) {
|
|
538
|
+
// We cast to `any` here since the type of ClickableBehaviorWithRouter
|
|
539
|
+
// is slightly different from the return type of this function.
|
|
540
|
+
// TODO(WB-1037): Always return the wrapped version once all routes have
|
|
541
|
+
// been ported to the app-shell in webapp.
|
|
542
|
+
return ClickableBehaviorWithRouter;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return ClickableBehavior;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const _excluded = ["href", "onClick", "skipClientNav", "beforeNav", "safeWithNav", "style", "target", "testId", "onKeyDown", "onKeyUp", "hideDefaultFocusRing", "light", "disabled"];
|
|
549
|
+
const StyledAnchor = addStyle("a");
|
|
550
|
+
const StyledButton = addStyle("button");
|
|
551
|
+
const StyledLink = addStyle(Link);
|
|
552
|
+
/**
|
|
553
|
+
* A component to turn any custom component into a clickable one.
|
|
554
|
+
*
|
|
555
|
+
* Works by wrapping ClickableBehavior around the child element and styling the
|
|
556
|
+
* child appropriately and encapsulates routing logic which can be customized.
|
|
557
|
+
* Expects a function which returns an element as it's child.
|
|
558
|
+
*
|
|
559
|
+
* Example usage:
|
|
560
|
+
* ```jsx
|
|
561
|
+
* <Clickable onClick={() => alert("You clicked me!")}>
|
|
562
|
+
* {({hovered, focused, pressed}) =>
|
|
563
|
+
* <div
|
|
564
|
+
* style={[
|
|
565
|
+
* hovered && styles.hovered,
|
|
566
|
+
* focused && styles.focused,
|
|
567
|
+
* pressed && styles.pressed,
|
|
568
|
+
* ]}
|
|
569
|
+
* >
|
|
570
|
+
* Click Me!
|
|
571
|
+
* </div>
|
|
572
|
+
* }
|
|
573
|
+
* </Clickable>
|
|
574
|
+
* ```
|
|
575
|
+
*/
|
|
576
|
+
|
|
577
|
+
class Clickable extends Component {
|
|
578
|
+
constructor(...args) {
|
|
579
|
+
super(...args);
|
|
580
|
+
|
|
581
|
+
this.getCorrectTag = (clickableState, commonProps) => {
|
|
582
|
+
const activeHref = this.props.href && !this.props.disabled;
|
|
583
|
+
const useClient = this.context.router && !this.props.skipClientNav && isClientSideUrl(this.props.href || ""); // NOTE: checking this.props.href here is redundant, but flow
|
|
584
|
+
// needs it to refine this.props.href to a string.
|
|
585
|
+
|
|
586
|
+
if (activeHref && useClient && this.props.href) {
|
|
587
|
+
return /*#__PURE__*/createElement(StyledLink, _extends({}, commonProps, {
|
|
588
|
+
to: this.props.href,
|
|
589
|
+
role: this.props.role,
|
|
590
|
+
target: this.props.target || undefined,
|
|
591
|
+
"aria-disabled": this.props.disabled ? "true" : undefined
|
|
592
|
+
}), this.props.children(clickableState));
|
|
593
|
+
} else if (activeHref && !useClient) {
|
|
594
|
+
return /*#__PURE__*/createElement(StyledAnchor, _extends({}, commonProps, {
|
|
595
|
+
href: this.props.href,
|
|
596
|
+
role: this.props.role,
|
|
597
|
+
target: this.props.target || undefined,
|
|
598
|
+
"aria-disabled": this.props.disabled ? "true" : undefined
|
|
599
|
+
}), this.props.children(clickableState));
|
|
600
|
+
} else {
|
|
601
|
+
return /*#__PURE__*/createElement(StyledButton, _extends({}, commonProps, {
|
|
602
|
+
type: "button",
|
|
603
|
+
disabled: this.props.disabled
|
|
604
|
+
}), this.props.children(clickableState));
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
render() {
|
|
610
|
+
const _this$props = this.props,
|
|
611
|
+
{
|
|
612
|
+
href,
|
|
613
|
+
onClick,
|
|
614
|
+
skipClientNav,
|
|
615
|
+
beforeNav = undefined,
|
|
616
|
+
safeWithNav = undefined,
|
|
617
|
+
style,
|
|
618
|
+
target = undefined,
|
|
619
|
+
testId,
|
|
620
|
+
onKeyDown,
|
|
621
|
+
onKeyUp,
|
|
622
|
+
hideDefaultFocusRing,
|
|
623
|
+
light,
|
|
624
|
+
disabled
|
|
625
|
+
} = _this$props,
|
|
626
|
+
restProps = _objectWithoutPropertiesLoose(_this$props, _excluded);
|
|
627
|
+
|
|
628
|
+
const ClickableBehavior = getClickableBehavior(href, skipClientNav, this.context.router);
|
|
629
|
+
|
|
630
|
+
const getStyle = state => [styles.reset, styles.link, !hideDefaultFocusRing && state.focused && (light ? styles.focusedLight : styles.focused), style];
|
|
631
|
+
|
|
632
|
+
if (beforeNav) {
|
|
633
|
+
return /*#__PURE__*/createElement(ClickableBehavior, {
|
|
634
|
+
href: href,
|
|
635
|
+
onClick: onClick,
|
|
636
|
+
beforeNav: beforeNav,
|
|
637
|
+
safeWithNav: safeWithNav,
|
|
638
|
+
onKeyDown: onKeyDown,
|
|
639
|
+
onKeyUp: onKeyUp,
|
|
640
|
+
disabled: disabled
|
|
641
|
+
}, (state, childrenProps) => this.getCorrectTag(state, _extends({}, restProps, {
|
|
642
|
+
"data-test-id": testId,
|
|
643
|
+
style: getStyle(state)
|
|
644
|
+
}, childrenProps)));
|
|
645
|
+
} else {
|
|
646
|
+
return /*#__PURE__*/createElement(ClickableBehavior, {
|
|
647
|
+
href: href,
|
|
648
|
+
onClick: onClick,
|
|
649
|
+
safeWithNav: safeWithNav,
|
|
650
|
+
onKeyDown: onKeyDown,
|
|
651
|
+
onKeyUp: onKeyUp,
|
|
652
|
+
target: target,
|
|
653
|
+
disabled: disabled
|
|
654
|
+
}, (state, childrenProps) => this.getCorrectTag(state, _extends({}, restProps, {
|
|
655
|
+
"data-test-id": testId,
|
|
656
|
+
style: getStyle(state)
|
|
657
|
+
}, childrenProps)));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
} // Source: https://gist.github.com/MoOx/9137295
|
|
662
|
+
|
|
663
|
+
Clickable.contextTypes = {
|
|
664
|
+
router: any
|
|
665
|
+
};
|
|
666
|
+
Clickable.defaultProps = {
|
|
667
|
+
light: false,
|
|
668
|
+
disabled: false,
|
|
669
|
+
"aria-label": ""
|
|
670
|
+
};
|
|
671
|
+
const styles = StyleSheet.create({
|
|
672
|
+
reset: {
|
|
673
|
+
border: "none",
|
|
674
|
+
margin: 0,
|
|
675
|
+
padding: 0,
|
|
676
|
+
width: "auto",
|
|
677
|
+
overflow: "visible",
|
|
678
|
+
background: "transparent",
|
|
679
|
+
textDecoration: "none",
|
|
680
|
+
|
|
681
|
+
/* inherit font & color from ancestor */
|
|
682
|
+
color: "inherit",
|
|
683
|
+
font: "inherit",
|
|
684
|
+
boxSizing: "border-box",
|
|
685
|
+
// This removes the 300ms click delay on mobile browsers by indicating that
|
|
686
|
+
// "double-tap to zoom" shouldn't be used on this element.
|
|
687
|
+
touchAction: "manipulation",
|
|
688
|
+
userSelect: "none",
|
|
689
|
+
// This is usual frowned upon b/c of accessibility. We expect users of Clickable
|
|
690
|
+
// to define their own focus styles.
|
|
691
|
+
outline: "none",
|
|
692
|
+
|
|
693
|
+
/* Normalize `line-height`. Cannot be changed from `normal` in Firefox 4+. */
|
|
694
|
+
lineHeight: "normal",
|
|
695
|
+
|
|
696
|
+
/* Corrects font smoothing for webkit */
|
|
697
|
+
WebkitFontSmoothing: "inherit",
|
|
698
|
+
MozOsxFontSmoothing: "inherit"
|
|
699
|
+
},
|
|
700
|
+
link: {
|
|
701
|
+
cursor: "pointer"
|
|
702
|
+
},
|
|
703
|
+
focused: {
|
|
704
|
+
outline: `solid 2px ${Color.blue}`
|
|
705
|
+
},
|
|
706
|
+
focusedLight: {
|
|
707
|
+
outline: `solid 2px ${Color.white}`
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
export default Clickable;
|
|
712
|
+
export { ClickableBehavior, getClickableBehavior, isClientSideUrl };
|