@khanacademy/perseus 0.3.4 → 0.4.1
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/CHANGELOG.md +30 -0
- package/dist/es/index.css +436 -1
- package/dist/es/index.js +8232 -5591
- package/dist/es/index.js.map +1 -1
- package/dist/index.css +436 -1
- package/dist/index.js +17876 -15237
- package/dist/index.js.map +1 -1
- package/dist/strings.js +0 -1
- package/package.json +9 -8
- package/src/__tests__/__snapshots__/item-renderer_test.jsx.snap +2 -3
- package/src/__tests__/__snapshots__/server-item-renderer_test.jsx.snap +2 -3
- package/src/__tests__/renderer-api_test.jsx +3 -27
- package/src/components/__stories__/svg-image.stories.jsx +11 -0
- package/src/components/fixed-to-responsive.jsx +1 -0
- package/src/components/input-with-examples.jsx +3 -1
- package/src/components/svg-image.jsx +10 -7
- package/src/components/text-input.jsx +65 -16
- package/src/definition-context.js +59 -0
- package/src/index.js +5 -1
- package/src/multi-items/__tests__/__snapshots__/multi-renderer_test.js.snap +438 -523
- package/src/multi-items/__tests__/multi-renderer_test.js +9 -9
- package/src/renderer.jsx +13 -6
- package/src/types.js +3 -4
- package/src/widgets/__stories__/definition.stories.jsx +44 -0
- package/src/widgets/__tests__/__snapshots__/categorizer_test.jsx.snap +76 -8
- package/src/widgets/__tests__/__snapshots__/graded-group-set-jipt_test.jsx.snap +6 -9
- package/src/widgets/__tests__/__snapshots__/graded-group-set_test.jsx.snap +2 -3
- package/src/widgets/__tests__/__snapshots__/grapher_test.jsx.snap +19 -2
- package/src/widgets/__tests__/__snapshots__/group_test.jsx.snap +338 -390
- package/src/widgets/__tests__/__snapshots__/image_test.jsx.snap +38 -4
- package/src/widgets/__tests__/__snapshots__/interactive-graph_test.jsx.snap +114 -12
- package/src/widgets/__tests__/__snapshots__/matrix_test.jsx.snap +120 -48
- package/src/widgets/__tests__/__snapshots__/numeric-input_test.jsx.snap +8 -12
- package/src/widgets/__tests__/__snapshots__/radio_test.jsx.snap +3615 -4517
- package/src/widgets/__tests__/graded-group-set-jipt_test.jsx +0 -1
- package/src/widgets/__tests__/grapher.cypress.jsx +236 -176
- package/src/widgets/__tests__/group_test.jsx +4 -4
- package/src/widgets/__tests__/interactive-graph.cypress.jsx +371 -291
- package/src/widgets/__tests__/radio_test.jsx +73 -114
- package/src/widgets/definition.jsx +34 -29
- package/src/widgets/numeric-input.jsx +2 -15
- package/src/widgets/radio/base-radio.jsx +1 -1
- package/src/widgets/radio/choice-icon.jsx +11 -10
- package/src/widgets/radio/choice.jsx +164 -396
- package/src/widgets/radio/cross-out-button.jsx +3 -5
- package/src/widgets/radio/option-status.jsx +4 -5
- package/src/widgets/radio/__stories__/cross-out-menu.stories.jsx +0 -59
- package/src/widgets/radio/__stories__/cross-out-tooltip.stories.jsx +0 -59
- package/src/widgets/radio/__stories__/toggleable-radio-button.stories.jsx +0 -28
- package/src/widgets/radio/cross-out-menu-wrapper.jsx +0 -492
- package/src/widgets/radio/cross-out-tooltip.jsx +0 -129
- package/src/widgets/radio/toggleable-radio-button.jsx +0 -133
|
@@ -1,492 +0,0 @@
|
|
|
1
|
-
/* eslint-disable react/no-unsafe */
|
|
2
|
-
// @flow
|
|
3
|
-
/**
|
|
4
|
-
* A wrapper for radio choice UI. This adds an ellipsis menu on its right-hand
|
|
5
|
-
* side that, when clicked, reveals a `CrossOutButton`.
|
|
6
|
-
*
|
|
7
|
-
* On desktop, we reveal this button by showing a simple tooltip.
|
|
8
|
-
*
|
|
9
|
-
* On mobile, we reveal this button by sliding the choice content to the left,
|
|
10
|
-
* to uncover a cross-out button underneath. (This is why we need to wrap the
|
|
11
|
-
* content!)
|
|
12
|
-
*/
|
|
13
|
-
import Color from "@khanacademy/wonder-blocks-color";
|
|
14
|
-
import * as i18n from "@khanacademy/wonder-blocks-i18n";
|
|
15
|
-
import {StyleSheet, css} from "aphrodite";
|
|
16
|
-
import * as React from "react";
|
|
17
|
-
|
|
18
|
-
import Icon from "../../components/icon.jsx";
|
|
19
|
-
import mediaQueries from "../../styles/media-queries.js";
|
|
20
|
-
|
|
21
|
-
import CrossOutButton from "./cross-out-button.jsx";
|
|
22
|
-
import CrossOutTooltip from "./cross-out-tooltip.jsx";
|
|
23
|
-
import {getChoiceLetter} from "./util.js";
|
|
24
|
-
|
|
25
|
-
// TODO(mdr): The icon in the cross-out spec isn't this one. The spec's icon
|
|
26
|
-
// rounder dots.
|
|
27
|
-
const ellipsisHorizontalIcon = {
|
|
28
|
-
path: "M27.218 6.82l0 13.578q0 2.852-1.984 4.836t-4.836 1.984l-13.578 0q-2.852 0-4.836-1.984t-1.984-4.836l0-13.578q0-2.852 1.984-4.836t4.836-1.984l13.578 0q2.852 0 4.836 1.984t1.984 4.836zm36.27 0l0 13.578q0 2.852-1.984 4.836t-4.836 1.984l-13.578 0q-2.852 0-4.836-1.984t-1.984-4.836l0-13.578q0-2.852 1.984-4.836t4.836-1.984l13.578 0q2.852 0 4.836 1.984t1.984 4.836zm36.27 0l0 13.578q0 2.852-1.984 4.836t-4.836 1.984l-13.578 0q-2.852 0-4.836-1.984t-1.984-4.836l0-13.578q0-2.852 1.984-4.836t4.836-1.984l13.578 0q2.852 0 4.836 1.984t1.984 4.836z",
|
|
29
|
-
width: 100,
|
|
30
|
-
height: 27.284,
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
type CrossOutMenuWrapperProps = {
|
|
34
|
-
// The main radio choice UI.
|
|
35
|
-
children: React.Node,
|
|
36
|
-
// Whether to show the cross-out UI at all.
|
|
37
|
-
enabled: boolean,
|
|
38
|
-
// The choice's position in the question, e.g., `0` for choice "A".
|
|
39
|
-
pos: number,
|
|
40
|
-
// The "primary product color" that Perseus has been configured with.
|
|
41
|
-
// Optional; defaults to `kaGreen`.
|
|
42
|
-
primaryProductColor?: string,
|
|
43
|
-
// Whether the choice is currently crossed out.
|
|
44
|
-
crossedOut: boolean,
|
|
45
|
-
// Callback for when the user requests to change the crossed-out state.
|
|
46
|
-
onCrossedOutChange: (crossedOut: boolean) => void,
|
|
47
|
-
// Whether cross out is for SAT tasks.
|
|
48
|
-
isSatProduct?: boolean,
|
|
49
|
-
...
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
type CrossOutMenuWrapperState = {
|
|
53
|
-
// Whether the menu area is being hovered.
|
|
54
|
-
//
|
|
55
|
-
// This controls the menu icon's color.
|
|
56
|
-
hovered: boolean,
|
|
57
|
-
// Whether the menu is currently open.
|
|
58
|
-
//
|
|
59
|
-
// This controls the tooltip on desktop, and the slide panel on mobile.
|
|
60
|
-
opened: boolean,
|
|
61
|
-
// Whether the slide panel for mobile is currently covered, even partially.
|
|
62
|
-
//
|
|
63
|
-
// This helps us decide what z-index to use: when covered, we want a low
|
|
64
|
-
// z-index, to be below the content; when uncovered, we want a high enough
|
|
65
|
-
// z-index to be clickable.
|
|
66
|
-
slidePanelCovered: boolean,
|
|
67
|
-
// Whether the user seems to be managing their keyboard focus. If so, we
|
|
68
|
-
// auto-focus the `tooltipButton` once the tooltip opens.
|
|
69
|
-
usingKeyboardFocus: boolean,
|
|
70
|
-
// Whether `crossedOut` was true when we last opened the menu.
|
|
71
|
-
//
|
|
72
|
-
// This helps us keep the slide-panel button visually constant, while we
|
|
73
|
-
// slide the content back over it.
|
|
74
|
-
//
|
|
75
|
-
// If we just use `this.props.crossedOut` instead, then the "Cross out"
|
|
76
|
-
// button switches to a different "Re-enable" button _right_ after you tap
|
|
77
|
-
// it, and then immediately starts to disappear. This is visually jarring,
|
|
78
|
-
// and liable to produce anxiety ("wait what was that, what did I miss?").
|
|
79
|
-
// It also gives the false impression that perhaps the user could tap very
|
|
80
|
-
// quickly to toggle it again, which isn't true, and it's not actually an
|
|
81
|
-
// interaction that we want to encourage: interrupting the slide animation
|
|
82
|
-
// would be visually messy, and would be liable to produce a mis-click on
|
|
83
|
-
// the answer choice itself instead of its menu.
|
|
84
|
-
wasCrossedOutOnLastOpen: boolean,
|
|
85
|
-
...
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
export default class CrossOutMenuWrapper extends React.PureComponent<
|
|
89
|
-
CrossOutMenuWrapperProps,
|
|
90
|
-
CrossOutMenuWrapperState,
|
|
91
|
-
> {
|
|
92
|
-
// A reference to our menu element.
|
|
93
|
-
_menu: ?HTMLDivElement;
|
|
94
|
-
|
|
95
|
-
// A reference to our menu button element.
|
|
96
|
-
_menuButton: ?HTMLButtonElement;
|
|
97
|
-
|
|
98
|
-
// Whether keyboard focus is currently inside a CrossOutButton.
|
|
99
|
-
_crossOutButtonIsFocused: boolean;
|
|
100
|
-
|
|
101
|
-
state: CrossOutMenuWrapperState = {
|
|
102
|
-
hovered: false,
|
|
103
|
-
opened: false,
|
|
104
|
-
slidePanelCovered: true,
|
|
105
|
-
usingKeyboardFocus: false,
|
|
106
|
-
wasCrossedOutOnLastOpen: false,
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
UNSAFE_componentWillReceiveProps(nextProps: CrossOutMenuWrapperProps) {
|
|
110
|
-
// If the menu becomes disabled, it should also become closed. (This
|
|
111
|
-
// helps us avoid permanently staying open when switching from
|
|
112
|
-
// non-review mode to review mode!)
|
|
113
|
-
if (!nextProps.enabled) {
|
|
114
|
-
this._closeMenu();
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Open the menu, and shift focus to the tooltip cross-out button, if
|
|
120
|
-
* necessary.
|
|
121
|
-
*
|
|
122
|
-
* NOTE(mdr): We only do focus-shifting for the tooltip case, because it's
|
|
123
|
-
* especially hard to tab-navigate to. But the slide panel button is
|
|
124
|
-
* easy to tab to, so we don't do any special focus behavior.
|
|
125
|
-
*/
|
|
126
|
-
_openMenu: () => void = () => {
|
|
127
|
-
// If keyboard focus is inside this component (e.g., the user
|
|
128
|
-
// tabbed to it and pressed Enter), set state that will cause us to
|
|
129
|
-
// auto-focus the tooltip content once it mounts. This is helpful
|
|
130
|
-
// if you care about element focus (e.g., primary keyboard user),
|
|
131
|
-
// but distracting if you don't (e.g., primary mouse user).
|
|
132
|
-
const thisComponentHasFocus =
|
|
133
|
-
this._menu != null && this._menu.contains(document.activeElement);
|
|
134
|
-
|
|
135
|
-
this.setState({
|
|
136
|
-
opened: true,
|
|
137
|
-
usingKeyboardFocus: thisComponentHasFocus,
|
|
138
|
-
wasCrossedOutOnLastOpen: this.props.crossedOut,
|
|
139
|
-
});
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Close the menu, and shift focus back out of the button, if necessary.
|
|
144
|
-
*/
|
|
145
|
-
_closeMenu: () => void = () => {
|
|
146
|
-
// If we're closing the menu, but keyboard focus is still inside the
|
|
147
|
-
// menu tooltip, move focus back to the menu button.
|
|
148
|
-
if (this._crossOutButtonIsFocused && this._menuButton) {
|
|
149
|
-
this._menuButton.focus();
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
this.setState({opened: false, slidePanelCovered: true});
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* When the menu is clicked, we call this handler to toggle the menu
|
|
157
|
-
* open/closed.
|
|
158
|
-
*/
|
|
159
|
-
_handleMenuClick: (
|
|
160
|
-
e: SyntheticMouseEvent<HTMLElement>,
|
|
161
|
-
willBeOpened: boolean,
|
|
162
|
-
) => void = (
|
|
163
|
-
e: SyntheticMouseEvent<HTMLElement>,
|
|
164
|
-
willBeOpened: boolean,
|
|
165
|
-
) => {
|
|
166
|
-
// Toggle the `opened` state. (Instead of reading `!this.state.opened`
|
|
167
|
-
// here, we use the value at the time the element was rendered. This
|
|
168
|
-
// helps us avoid double-toggling if we receive events from both the
|
|
169
|
-
// `menuButton` and `menuWrapper`.)
|
|
170
|
-
if (willBeOpened) {
|
|
171
|
-
this._openMenu();
|
|
172
|
-
} else {
|
|
173
|
-
this._closeMenu();
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// This click shouldn't count as a click on the parent <label> element.
|
|
177
|
-
// TODO(mdr): Should we move the <label> to not include this menu?
|
|
178
|
-
e.preventDefault();
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* When the tooltip is clicked, we call this handler to toggle the
|
|
183
|
-
* cross-out state and close the tooltip.
|
|
184
|
-
*/
|
|
185
|
-
_handleCrossedOutButtonClick: (willBeCrossedOut: boolean) => void = (
|
|
186
|
-
willBeCrossedOut: boolean,
|
|
187
|
-
) => {
|
|
188
|
-
// Toggle the `crossedOut` state. (Instead of reading
|
|
189
|
-
// `!this.props.crossedOut` here, we use the value at the time the
|
|
190
|
-
// element was rendered. This helps us avoid double-toggling if we
|
|
191
|
-
// receive events from both the `menuButton` and `menuWrapper`.)
|
|
192
|
-
//
|
|
193
|
-
// NOTE(mdr): This *shouldn't* be an issue, because the current React
|
|
194
|
-
// lifecycle seems to not give us new props during a click
|
|
195
|
-
// handler's bubbling... but it seems good to be defensive!
|
|
196
|
-
this.props.onCrossedOutChange(willBeCrossedOut);
|
|
197
|
-
this._closeMenu();
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* When we finish the slide-open animation, record that the slide panel is
|
|
202
|
-
* now uncovered.
|
|
203
|
-
*/
|
|
204
|
-
_handleSlideTransitionEnd: () => void = () => {
|
|
205
|
-
if (this.state.opened) {
|
|
206
|
-
this.setState({slidePanelCovered: false});
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Build props for the `CrossOutButton`, which we'll use in both the
|
|
212
|
-
* tooltip's button (desktop) and the slide panel's button (mobile).
|
|
213
|
-
*/
|
|
214
|
-
_getButtonProps(theme: "day" | "night"): {|
|
|
215
|
-
crossedOut: boolean,
|
|
216
|
-
disabled?: boolean,
|
|
217
|
-
onBlur: () => void,
|
|
218
|
-
onClick: () => void,
|
|
219
|
-
onFocus: () => void,
|
|
220
|
-
pos: number,
|
|
221
|
-
primaryProductColor?: string,
|
|
222
|
-
tabFocusable?: boolean,
|
|
223
|
-
theme: "day" | "night",
|
|
224
|
-
usingKeyboardFocus: boolean,
|
|
225
|
-
|} {
|
|
226
|
-
const wasCrossedOut = this.state.wasCrossedOutOnLastOpen;
|
|
227
|
-
|
|
228
|
-
return {
|
|
229
|
-
onClick: () => {
|
|
230
|
-
this._handleCrossedOutButtonClick(!wasCrossedOut);
|
|
231
|
-
},
|
|
232
|
-
onFocus: () => {
|
|
233
|
-
this._crossOutButtonIsFocused = true;
|
|
234
|
-
},
|
|
235
|
-
onBlur: () => {
|
|
236
|
-
this._crossOutButtonIsFocused = false;
|
|
237
|
-
},
|
|
238
|
-
crossedOut: wasCrossedOut,
|
|
239
|
-
pos: this.props.pos,
|
|
240
|
-
primaryProductColor: this.props.primaryProductColor,
|
|
241
|
-
usingKeyboardFocus: this.state.usingKeyboardFocus,
|
|
242
|
-
theme,
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
_getMenuIconColor(): void | string {
|
|
247
|
-
if (this.state.opened) {
|
|
248
|
-
return this.props.primaryProductColor;
|
|
249
|
-
}
|
|
250
|
-
if (this.state.hovered) {
|
|
251
|
-
return Color.offBlack;
|
|
252
|
-
}
|
|
253
|
-
return Color.offBlack50;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
_renderMenu(): React.Element<"div"> {
|
|
257
|
-
const {pos, isSatProduct} = this.props;
|
|
258
|
-
const {opened} = this.state;
|
|
259
|
-
const menuStyles = css(styles.menu, isSatProduct && styles.spacing);
|
|
260
|
-
|
|
261
|
-
return (
|
|
262
|
-
// This whole wrapper is a valid click target for opening the menu,
|
|
263
|
-
// and it's where we track hover state. But this isn't the main
|
|
264
|
-
// _semantic_ click target, and it's not focusable; see the
|
|
265
|
-
// <button>.
|
|
266
|
-
<div
|
|
267
|
-
ref={(el) => (this._menu = el)}
|
|
268
|
-
className={menuStyles}
|
|
269
|
-
onClick={(e) => this._handleMenuClick(e, !opened)}
|
|
270
|
-
onMouseEnter={() => this.setState({hovered: true})}
|
|
271
|
-
onMouseLeave={() => this.setState({hovered: false})}
|
|
272
|
-
>
|
|
273
|
-
<CrossOutTooltip
|
|
274
|
-
// This tooltip is "dismissed" unless the menu is open.
|
|
275
|
-
dismissed={!this.state.opened}
|
|
276
|
-
onDismiss={this._closeMenu}
|
|
277
|
-
buttonProps={this._getButtonProps("day")}
|
|
278
|
-
>
|
|
279
|
-
{/* This button is the main semantic click target. It
|
|
280
|
-
* receives focus when you tab to it, and has a nice small
|
|
281
|
-
* square focus outline. But the entire wrapper element is
|
|
282
|
-
* a valid click target, too! */}
|
|
283
|
-
<button
|
|
284
|
-
type="button"
|
|
285
|
-
aria-label={i18n._("Open menu for Choice %(letter)s", {
|
|
286
|
-
letter: getChoiceLetter(pos),
|
|
287
|
-
})}
|
|
288
|
-
className={css([styles.menuButton])}
|
|
289
|
-
onClick={(e) => this._handleMenuClick(e, !opened)}
|
|
290
|
-
// Don't focus this element on mousedown. It's visually
|
|
291
|
-
// distracting, and confuses our "should we shift focus"
|
|
292
|
-
// logic for keyboard users.
|
|
293
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
294
|
-
ref={(el) => (this._menuButton = el)}
|
|
295
|
-
>
|
|
296
|
-
<Icon
|
|
297
|
-
className={css(styles.menuIcon)}
|
|
298
|
-
icon={ellipsisHorizontalIcon}
|
|
299
|
-
size={3}
|
|
300
|
-
color={this._getMenuIconColor()}
|
|
301
|
-
/>
|
|
302
|
-
</button>
|
|
303
|
-
</CrossOutTooltip>
|
|
304
|
-
</div>
|
|
305
|
-
);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
_renderSlidePanel(): React.Element<"div"> {
|
|
309
|
-
const buttonProps = this._getButtonProps("night");
|
|
310
|
-
const {slidePanelCovered} = this.state;
|
|
311
|
-
|
|
312
|
-
return (
|
|
313
|
-
<div
|
|
314
|
-
// The whole slide panel acts as an extra click target, but the
|
|
315
|
-
// semantic click target (and focusable element) is the button
|
|
316
|
-
// itself.
|
|
317
|
-
className={css(
|
|
318
|
-
styles.slidePanel,
|
|
319
|
-
slidePanelCovered && styles.slidePanelCovered,
|
|
320
|
-
)}
|
|
321
|
-
onClick={buttonProps.onClick}
|
|
322
|
-
aria-hidden={slidePanelCovered}
|
|
323
|
-
>
|
|
324
|
-
<CrossOutButton
|
|
325
|
-
{...buttonProps}
|
|
326
|
-
disabled={slidePanelCovered}
|
|
327
|
-
tabFocusable={!slidePanelCovered}
|
|
328
|
-
/>
|
|
329
|
-
</div>
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
render(): React.Element<"div"> {
|
|
334
|
-
const {enabled} = this.props;
|
|
335
|
-
const {opened} = this.state;
|
|
336
|
-
|
|
337
|
-
// There are three main visual elements being composed here:
|
|
338
|
-
//
|
|
339
|
-
// 1. The choice content, provided as `this.props.children`.
|
|
340
|
-
// 2. The menu element (ellipsis), which toggles the menu open/closed.
|
|
341
|
-
// (The tooltip on desktop is included as part of the menu element.)
|
|
342
|
-
// 3. The slide panel, which sits beneath the other elements unless the
|
|
343
|
-
// menu is open and we're on mobile.
|
|
344
|
-
//
|
|
345
|
-
// On mobile, in order to show the slide panel, we need to slide
|
|
346
|
-
// elements #1 and #2 to the left. So, we wrap them together in a
|
|
347
|
-
// `slidingContent` element, which controls the actual slide animation.
|
|
348
|
-
//
|
|
349
|
-
// The slide panel itself is a sibling of `slideContent`, and it's
|
|
350
|
-
// absolute-positioned relative to the wrapper. It has a negative
|
|
351
|
-
// z-index when covered.
|
|
352
|
-
//
|
|
353
|
-
// Incidentally, we keep using all these same wrapper elements, even
|
|
354
|
-
// when `this.props.enabled` is false. This helps performance when
|
|
355
|
-
// switching from enabled (non-review mode) to disabled (review mode),
|
|
356
|
-
// because we can reuse the same DOM tree, and just remove the menu and
|
|
357
|
-
// slide panel elements.
|
|
358
|
-
return (
|
|
359
|
-
<div className={css(styles.wrapper)}>
|
|
360
|
-
<div
|
|
361
|
-
className={css(
|
|
362
|
-
styles.slidingContent,
|
|
363
|
-
enabled && styles.slidingContentEnabled,
|
|
364
|
-
opened && styles.slidingContentOpen,
|
|
365
|
-
)}
|
|
366
|
-
onTransitionEnd={this._handleSlideTransitionEnd}
|
|
367
|
-
>
|
|
368
|
-
<div className={css(styles.content)}>
|
|
369
|
-
{this.props.children}
|
|
370
|
-
</div>
|
|
371
|
-
{enabled && this._renderMenu()}
|
|
372
|
-
</div>
|
|
373
|
-
{enabled && this._renderSlidePanel()}
|
|
374
|
-
</div>
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const SLIDE_PANEL_WIDTH = 96;
|
|
380
|
-
|
|
381
|
-
const styles = StyleSheet.create({
|
|
382
|
-
wrapper: {
|
|
383
|
-
display: "block", // HACK(mdr): Override `display: inline` in LESS :/
|
|
384
|
-
position: "relative",
|
|
385
|
-
},
|
|
386
|
-
|
|
387
|
-
slidingContent: {
|
|
388
|
-
display: "flex",
|
|
389
|
-
flexDirection: "row",
|
|
390
|
-
alignItems: "stretch",
|
|
391
|
-
},
|
|
392
|
-
|
|
393
|
-
slidingContentEnabled: {
|
|
394
|
-
// Only apply this transition when the menu is enabled. That way, when
|
|
395
|
-
// we switch from enabled to disabled with the menu open, we snap right
|
|
396
|
-
// to being closed, rather than awkwardly _sliding_ closed.
|
|
397
|
-
[mediaQueries.smOrSmaller]: {
|
|
398
|
-
transition: "transform 0.2s ease-out",
|
|
399
|
-
backgroundColor: Color.white,
|
|
400
|
-
},
|
|
401
|
-
},
|
|
402
|
-
|
|
403
|
-
slidingContentOpen: {
|
|
404
|
-
[mediaQueries.smOrSmaller]: {
|
|
405
|
-
transform: `translateX(-${SLIDE_PANEL_WIDTH}px)`,
|
|
406
|
-
},
|
|
407
|
-
},
|
|
408
|
-
|
|
409
|
-
content: {
|
|
410
|
-
display: "block", // HACK(mdr): Override `display: inline` in LESS :/
|
|
411
|
-
flex: "1 1 0",
|
|
412
|
-
},
|
|
413
|
-
|
|
414
|
-
menu: {
|
|
415
|
-
cursor: "pointer",
|
|
416
|
-
boxSizing: "border-box",
|
|
417
|
-
width: 32,
|
|
418
|
-
display: "flex",
|
|
419
|
-
flexDirection: "column",
|
|
420
|
-
alignItems: "flex-end",
|
|
421
|
-
justifyContent: "center",
|
|
422
|
-
|
|
423
|
-
// On phone screens, the multiple-choice answer is flush with the
|
|
424
|
-
// viewport, which is confusing and makes for a bad click target. So,
|
|
425
|
-
// we want to give some extra space between the dots and the edge of
|
|
426
|
-
// the screen. But, on desktop, it's nicer to align the dots to the
|
|
427
|
-
// right edge of the answer.
|
|
428
|
-
//
|
|
429
|
-
// HACK(mdr): The point at which answers _actually_ end up flush with
|
|
430
|
-
// the viewport depends on the embedding application. This media
|
|
431
|
-
// query is just a heuristic. But the in-between cases look fine
|
|
432
|
-
// with the extra space anyway, so, well, thems the hacks!
|
|
433
|
-
[mediaQueries.smOrSmaller]: {
|
|
434
|
-
paddingRight: 8,
|
|
435
|
-
},
|
|
436
|
-
},
|
|
437
|
-
|
|
438
|
-
menuButton: {
|
|
439
|
-
// Reset <button> styles to be more like <div>.
|
|
440
|
-
background: "none",
|
|
441
|
-
border: "none",
|
|
442
|
-
font: "inherit",
|
|
443
|
-
padding: 0,
|
|
444
|
-
textAlign: "inherit",
|
|
445
|
-
|
|
446
|
-
cursor: "pointer",
|
|
447
|
-
display: "flex",
|
|
448
|
-
flexDirection: "column",
|
|
449
|
-
justifyContent: "center",
|
|
450
|
-
height: 16,
|
|
451
|
-
},
|
|
452
|
-
|
|
453
|
-
menuIcon: {
|
|
454
|
-
// The target of a NewTooltip must be a block-like element.
|
|
455
|
-
display: "block",
|
|
456
|
-
},
|
|
457
|
-
|
|
458
|
-
slidePanel: {
|
|
459
|
-
position: "absolute",
|
|
460
|
-
top: 0,
|
|
461
|
-
right: 0,
|
|
462
|
-
|
|
463
|
-
boxSizing: "border-box",
|
|
464
|
-
width: SLIDE_PANEL_WIDTH,
|
|
465
|
-
height: "100%",
|
|
466
|
-
paddingLeft: 8,
|
|
467
|
-
paddingRight: 8,
|
|
468
|
-
|
|
469
|
-
display: "flex",
|
|
470
|
-
flexDirection: "row",
|
|
471
|
-
alignItems: "center",
|
|
472
|
-
justifyContent: "center",
|
|
473
|
-
|
|
474
|
-
background: Color.offBlack50,
|
|
475
|
-
cursor: "pointer",
|
|
476
|
-
|
|
477
|
-
[mediaQueries.mdOrLarger]: {
|
|
478
|
-
display: "none",
|
|
479
|
-
},
|
|
480
|
-
},
|
|
481
|
-
|
|
482
|
-
spacing: {
|
|
483
|
-
paddingRight: 12,
|
|
484
|
-
},
|
|
485
|
-
|
|
486
|
-
slidePanelCovered: {
|
|
487
|
-
// When covered, use a z-index that puts us below the block-layout
|
|
488
|
-
// content. This also makes the slide panel unclickable, so we _only_
|
|
489
|
-
// apply it when the panel should be covered.
|
|
490
|
-
zIndex: -1,
|
|
491
|
-
},
|
|
492
|
-
});
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
// @flow
|
|
2
|
-
/**
|
|
3
|
-
* The tooltip that appears when you open the `CrossOutMenu` on desktop,
|
|
4
|
-
* enabling the user to toggle a choice's `crossedOut` state.
|
|
5
|
-
*/
|
|
6
|
-
import * as React from "react";
|
|
7
|
-
|
|
8
|
-
import NewTooltip from "../../components/new-tooltip/new-tooltip.jsx";
|
|
9
|
-
import mediaQueries from "../../styles/media-queries.js";
|
|
10
|
-
|
|
11
|
-
import CrossOutButton, {type CrossOutButtonProps} from "./cross-out-button.jsx";
|
|
12
|
-
|
|
13
|
-
export type CrossOutTooltipProps = {
|
|
14
|
-
children: React$Element<any>,
|
|
15
|
-
dismissed: boolean,
|
|
16
|
-
onDismiss: () => void,
|
|
17
|
-
// Props to pass directly to the CrossOutButton.
|
|
18
|
-
buttonProps: CrossOutButtonProps,
|
|
19
|
-
...
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export default class CrossOutTooltip extends React.PureComponent<CrossOutTooltipProps> {
|
|
23
|
-
/**
|
|
24
|
-
* When you press Tab or Shift-Tab from the tooltip button, we call this
|
|
25
|
-
* handler to dismiss the tooltip and bring you back to the menu button.
|
|
26
|
-
*/
|
|
27
|
-
_handleKeyDown: (e: SyntheticKeyboardEvent<HTMLElement>) => void = (
|
|
28
|
-
e: SyntheticKeyboardEvent<HTMLElement>,
|
|
29
|
-
) => {
|
|
30
|
-
// We don't need to check for the shift key _also_ being applied; we
|
|
31
|
-
// can just listen for all keystrokes where the primary key is Tab.
|
|
32
|
-
if (e.key === "Tab") {
|
|
33
|
-
this.props.onDismiss();
|
|
34
|
-
e.preventDefault();
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
_renderTooltipContent(): React.Element<"div"> {
|
|
39
|
-
return (
|
|
40
|
-
// The button is the main semantic click target of the tooltip,
|
|
41
|
-
// which can receive tab focus (and automatically does when the
|
|
42
|
-
// flag `usingKeyboardFocus` is set!). However, the entire tooltip
|
|
43
|
-
// serves as a click target as well; see the <NewTooltip />.
|
|
44
|
-
<div onKeyDown={this._handleKeyDown}>
|
|
45
|
-
<CrossOutButton {...this.props.buttonProps} />
|
|
46
|
-
</div>
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
render(): React.Element<"div"> {
|
|
51
|
-
return (
|
|
52
|
-
<div>
|
|
53
|
-
<NewTooltip
|
|
54
|
-
content={this._renderTooltipContent()}
|
|
55
|
-
offsetFrom="arrow"
|
|
56
|
-
offset={10}
|
|
57
|
-
dismissed={this.props.dismissed}
|
|
58
|
-
showOnMount={true}
|
|
59
|
-
toggleOnHover={false}
|
|
60
|
-
onClick={() => this.props.buttonProps.onClick()}
|
|
61
|
-
// On smOrSmaller, hide this tooltip, and use the slide
|
|
62
|
-
// panel in `CrossOutMenuWrapper` instead.
|
|
63
|
-
media={mediaQueries.mdOrLarger}
|
|
64
|
-
>
|
|
65
|
-
{this.props.children}
|
|
66
|
-
</NewTooltip>
|
|
67
|
-
|
|
68
|
-
{/* If the menu is open, register global dismissal handlers
|
|
69
|
-
* that listen for clicks elsewhere in the page, or Escape
|
|
70
|
-
* keypresses. This component renders nothing; it just
|
|
71
|
-
* registers/unregisters handlers on mount/unmount. */}
|
|
72
|
-
{!this.props.dismissed && (
|
|
73
|
-
<GlobalDismissalHandlers onDismiss={this.props.onDismiss} />
|
|
74
|
-
)}
|
|
75
|
-
</div>
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* A component that, when mounted, listens for dismissal-y events: clicking
|
|
82
|
-
* anywhere on the page, or pressing the Escape key. We handle these events by
|
|
83
|
-
* calling `onDismiss`.
|
|
84
|
-
*
|
|
85
|
-
* HACK(mdr): To generalize this component, we probably should ignore clicks
|
|
86
|
-
* inside the tooltip itself. However, conveniently, the `onClick` handler
|
|
87
|
-
* we receive from CrossOutMenu includes dismissal anyway. So, it's safe to
|
|
88
|
-
* dismiss on _any_ click, not just those outside the tooltip.
|
|
89
|
-
*/
|
|
90
|
-
class GlobalDismissalHandlers extends React.PureComponent<{
|
|
91
|
-
onDismiss: () => void,
|
|
92
|
-
...
|
|
93
|
-
}> {
|
|
94
|
-
componentDidMount() {
|
|
95
|
-
// Register these listeners after a tick, so they don't catch the click
|
|
96
|
-
// or keypress that _caused_ the tooltip to mount.
|
|
97
|
-
// TODO(jeff, CP-3128): Use Wonder Blocks Timing API
|
|
98
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
99
|
-
setTimeout(() => {
|
|
100
|
-
window.addEventListener("click", this._handleGlobalClick);
|
|
101
|
-
window.addEventListener("keyup", this._handleGlobalKeyup);
|
|
102
|
-
}, 0);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
componentWillUnmount() {
|
|
106
|
-
window.removeEventListener("click", this._handleGlobalClick);
|
|
107
|
-
window.removeEventListener("keyup", this._handleGlobalKeyup);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* On clicking anywhere, trigger dismissal.
|
|
112
|
-
*/
|
|
113
|
-
_handleGlobalClick = () => {
|
|
114
|
-
this.props.onDismiss();
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* If the key was Escape, trigger dismissal.
|
|
119
|
-
*/
|
|
120
|
-
_handleGlobalKeyup = (e: KeyboardEvent) => {
|
|
121
|
-
if (e.key === "Escape") {
|
|
122
|
-
this.props.onDismiss();
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
render(): React.Node {
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
}
|