@khanacademy/perseus-editor 0.0.1 → 0.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.
Files changed (127) hide show
  1. package/.babelrc.js +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/dist/es/index.css +1020 -0
  4. package/dist/es/index.js +9 -1
  5. package/dist/es/index.js.map +1 -1
  6. package/dist/index.css +1020 -0
  7. package/dist/index.js +9 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/strings.js +6 -1
  10. package/package.json +16 -4
  11. package/src/README.md +7 -0
  12. package/src/__stories__/editor.stories.js +33 -0
  13. package/src/__stories__/section-control-button.stories.jsx +27 -0
  14. package/src/__testdata__/input-number_testdata.js +28 -0
  15. package/src/__tests__/editor_test.jsx +123 -0
  16. package/src/__tests__/i18n_test.jsx +585 -0
  17. package/src/__tests__/traversal_test.jsx +378 -0
  18. package/src/all-editors.js +85 -0
  19. package/src/all-editors_test.js +17 -0
  20. package/src/all-widgets.js +10 -0
  21. package/src/article-editor.jsx +437 -0
  22. package/src/components/__stories__/blur-input.stories.jsx +24 -0
  23. package/src/components/blur-input.jsx +62 -0
  24. package/src/components/checkbox.jsx +194 -0
  25. package/src/components/device-framer.jsx +91 -0
  26. package/src/components/drag-target.jsx +88 -0
  27. package/src/components/dropdown-option.jsx +318 -0
  28. package/src/components/form-wrapped-text-field.jsx +248 -0
  29. package/src/components/graph-settings.jsx +631 -0
  30. package/src/components/hover-behavior.jsx +178 -0
  31. package/src/components/json-editor.jsx +121 -0
  32. package/src/components/link.jsx +173 -0
  33. package/src/components/simple-button.jsx +86 -0
  34. package/src/components/sortable.jsx +186 -0
  35. package/src/components/util.js +39 -0
  36. package/src/components/viewport-resizer.jsx +73 -0
  37. package/src/diffs/__stories__/perseus-diff-wrapper.jsx +8 -0
  38. package/src/diffs/__stories__/structured-item-diff.stories.jsx +855 -0
  39. package/src/diffs/__stories__/tags-diff.stories.jsx +38 -0
  40. package/src/diffs/__stories__/text-diff.stories.jsx +37 -0
  41. package/src/diffs/__tests__/split-diff_test.js +34 -0
  42. package/src/diffs/__tests__/string-array-diff_test.js +74 -0
  43. package/src/diffs/__tests__/structured-item-diff_test.js +432 -0
  44. package/src/diffs/__tests__/widget-diff-performer_test.js +44 -0
  45. package/src/diffs/article-diff.jsx +70 -0
  46. package/src/diffs/item-diff.jsx +77 -0
  47. package/src/diffs/renderer-diff.jsx +143 -0
  48. package/src/diffs/split-diff.jsx +30 -0
  49. package/src/diffs/string-array-diff.jsx +75 -0
  50. package/src/diffs/structured-item-diff.jsx +269 -0
  51. package/src/diffs/tags-diff.jsx +50 -0
  52. package/src/diffs/text-diff.jsx +194 -0
  53. package/src/diffs/widget-diff-performer.jsx +74 -0
  54. package/src/diffs/widget-diff.jsx +291 -0
  55. package/src/editor-page.jsx +351 -0
  56. package/src/editor.jsx +1364 -0
  57. package/src/hint-editor.jsx +447 -0
  58. package/src/i18n.jsx +139 -0
  59. package/src/iframe-content-renderer.jsx +201 -0
  60. package/src/index.js +27 -8
  61. package/src/item-editor.jsx +159 -0
  62. package/src/item-extras-editor.jsx +150 -0
  63. package/src/katex-error-view.jsx +87 -0
  64. package/src/multirenderer-editor.jsx +1074 -0
  65. package/src/section-control-button.jsx +53 -0
  66. package/src/stateful-article-editor.jsx +176 -0
  67. package/src/stateful-editor-page.jsx +90 -0
  68. package/src/styles/perseus-editor.less +1339 -0
  69. package/src/testing-widgets.js +12 -0
  70. package/src/util/register-all-widgets-and-editors-for-testing.js +15 -0
  71. package/src/util/register-all-widgets-and-editors-for-testing_test.js +18 -0
  72. package/src/widgets/__stories__/label-image-editor.stories.jsx +97 -0
  73. package/src/widgets/categorizer-editor.jsx +103 -0
  74. package/src/widgets/cs-program-editor.jsx +258 -0
  75. package/src/widgets/definition-editor.jsx +75 -0
  76. package/src/widgets/dropdown-editor.jsx +201 -0
  77. package/src/widgets/example-graphie-widget-editor.jsx +70 -0
  78. package/src/widgets/example-graphie-widget.jsx +175 -0
  79. package/src/widgets/example-widget-editor.jsx +60 -0
  80. package/src/widgets/example-widget.jsx +153 -0
  81. package/src/widgets/explanation-editor.jsx +89 -0
  82. package/src/widgets/expression-editor.jsx +560 -0
  83. package/src/widgets/graded-group-editor.jsx +162 -0
  84. package/src/widgets/graded-group-set-editor.jsx +101 -0
  85. package/src/widgets/grapher-editor.jsx +154 -0
  86. package/src/widgets/group-editor.jsx +83 -0
  87. package/src/widgets/iframe-editor.jsx +200 -0
  88. package/src/widgets/image-editor.jsx +326 -0
  89. package/src/widgets/input-number-editor.jsx +269 -0
  90. package/src/widgets/interaction/constraint-editor.jsx +147 -0
  91. package/src/widgets/interaction-editor.jsx +1389 -0
  92. package/src/widgets/interactive-graph-editor.jsx +322 -0
  93. package/src/widgets/label-image/__stories__/answer-choices.stories.jsx +81 -0
  94. package/src/widgets/label-image/__stories__/behavior.stories.jsx +32 -0
  95. package/src/widgets/label-image/__stories__/marker.stories.jsx +76 -0
  96. package/src/widgets/label-image/__stories__/question-markers.stories.jsx +106 -0
  97. package/src/widgets/label-image/__stories__/select-image.stories.jsx +60 -0
  98. package/src/widgets/label-image/answer-choices.jsx +231 -0
  99. package/src/widgets/label-image/behavior.jsx +92 -0
  100. package/src/widgets/label-image/marker.jsx +292 -0
  101. package/src/widgets/label-image/question-markers.jsx +181 -0
  102. package/src/widgets/label-image/select-image.jsx +86 -0
  103. package/src/widgets/label-image-editor.jsx +257 -0
  104. package/src/widgets/lights-puzzle-editor.jsx +267 -0
  105. package/src/widgets/matcher-editor.jsx +147 -0
  106. package/src/widgets/matrix-editor.jsx +155 -0
  107. package/src/widgets/measurer-editor.jsx +245 -0
  108. package/src/widgets/molecule-editor.jsx +63 -0
  109. package/src/widgets/number-line-editor.jsx +498 -0
  110. package/src/widgets/numeric-input-editor.jsx +554 -0
  111. package/src/widgets/orderer-editor.jsx +166 -0
  112. package/src/widgets/passage-editor.jsx +115 -0
  113. package/src/widgets/passage-ref-editor.jsx +77 -0
  114. package/src/widgets/passage-ref-target-editor.jsx +47 -0
  115. package/src/widgets/plotter-editor.jsx +537 -0
  116. package/src/widgets/radio/editor.jsx +398 -0
  117. package/src/widgets/reaction-diagram-editor.jsx +127 -0
  118. package/src/widgets/sequence-editor.jsx +205 -0
  119. package/src/widgets/simple-markdown-tester-editor.jsx +84 -0
  120. package/src/widgets/simple-markdown-tester.jsx +91 -0
  121. package/src/widgets/simulator-editor.jsx +144 -0
  122. package/src/widgets/sorter-editor.jsx +100 -0
  123. package/src/widgets/table-editor.jsx +164 -0
  124. package/src/widgets/transformer-editor.jsx +1006 -0
  125. package/src/widgets/unit-editor.jsx +293 -0
  126. package/src/widgets/video-editor.jsx +74 -0
  127. package/src/__tests__/index.test.js +0 -15
@@ -0,0 +1,194 @@
1
+ // @flow
2
+ /**
3
+ * Reusable checkbox component.
4
+ **/
5
+ import {globalStyles} from "@khanacademy/perseus";
6
+ import {css, StyleSheet} from "aphrodite";
7
+ import * as React from "react";
8
+
9
+ import HoverBehavior from "./hover-behavior.jsx";
10
+
11
+ const {borderRadius, colors} = globalStyles;
12
+
13
+ export type CheckboxProps = {
14
+ // Whether or not the checkbox is checked. The parent responsible for
15
+ // responding to onChange events and updating this prop appropriately.
16
+ checked: boolean,
17
+ // When disable the checkbox will appear faded out and will not
18
+ // respond to events and cannot be tabbed to.
19
+ disabled?: boolean,
20
+ // Appear as if disabled but allow it to respond to events and be
21
+ // tabbed to.
22
+ // This allows us to hook alternate responses into clicking on a
23
+ // disabled checkbox.
24
+ appearDisabled?: boolean,
25
+ // Called when the value has changed.
26
+ onChange: (event: SyntheticInputEvent<>) => void,
27
+ tabIndex?: number | string,
28
+ style?: {|
29
+ marginTop: number,
30
+ |},
31
+ // For e2e test
32
+ dataTestId?: string,
33
+ // Unique identifier for this checkbox.
34
+ id?: string,
35
+ ...
36
+ };
37
+
38
+ // NOTE(jangmi): Checkbox may have `onChange` depends on circumstances but lack
39
+ // of onChange causes Dev only alert so we provide the no op func as default
40
+ // to prevent that.
41
+ const onChangeCheckboxNoop = (event: SyntheticInputEvent<>) => {};
42
+
43
+ export default class Checkbox extends React.Component<CheckboxProps> {
44
+ static defaultProps: {|
45
+ checked: boolean,
46
+ onChange: (event: SyntheticInputEvent<>) => void,
47
+ |} = {
48
+ checked: false,
49
+ onChange: onChangeCheckboxNoop,
50
+ };
51
+
52
+ render(): React.Node {
53
+ const {
54
+ checked,
55
+ disabled,
56
+ appearDisabled,
57
+ onChange,
58
+ tabIndex,
59
+ style,
60
+ dataTestId,
61
+ id,
62
+ } = this.props;
63
+
64
+ const checkedColor = colors.gray41;
65
+
66
+ return (
67
+ <HoverBehavior>
68
+ {({focused}, handlers) => (
69
+ <div
70
+ {...handlers}
71
+ className={css(
72
+ styles.container,
73
+ focused && styles.focused,
74
+ )}
75
+ style={style}
76
+ data-test-id={dataTestId}
77
+ data-checked={checked}
78
+ >
79
+ <svg
80
+ className={css(
81
+ styles.svg,
82
+ (disabled || appearDisabled) && styles.disabled,
83
+ )}
84
+ width={sizeWithPadding}
85
+ height={sizeWithPadding}
86
+ /* pad the viewbox to avoid bad clipping when zooming */
87
+ viewBox={`-${padding} -${padding}
88
+ ${sizeWithPadding} ${sizeWithPadding}`}
89
+ >
90
+ <g fill="none" fillRule="evenodd">
91
+ {checked && (
92
+ <g>
93
+ <rect
94
+ fill={checkedColor}
95
+ width={size}
96
+ height={size}
97
+ x="0"
98
+ y="0"
99
+ rx={borderRadius}
100
+ />
101
+ <path
102
+ fill={colors.white}
103
+ stroke={colors.white}
104
+ d="M4.98 7.41a0.58.58 0 1 0-0.81.81L6.47
105
+ 10.53c0.23.23.59.23.81
106
+ 0l4.55-4.55a0.58.58 0 0
107
+ 0-0.81-0.81L6.88 9.31 4.98 7.41z"
108
+ />
109
+ </g>
110
+ )}
111
+ {!checked && (
112
+ <rect
113
+ fill={colors.white}
114
+ stroke={colors.gray68}
115
+ width={size - 2 * padding}
116
+ height={size - 2 * padding}
117
+ x={padding}
118
+ y={padding}
119
+ rx="4"
120
+ strokeWidth={borderWidth}
121
+ />
122
+ )}
123
+ </g>
124
+ </svg>
125
+ <input
126
+ type="checkbox"
127
+ id={id}
128
+ checked={checked}
129
+ className={css(
130
+ styles.checkbox,
131
+ disabled && styles.defaultCursor,
132
+ )}
133
+ disabled={disabled}
134
+ onChange={onChange}
135
+ tabIndex={tabIndex}
136
+ />
137
+ </div>
138
+ )}
139
+ </HoverBehavior>
140
+ );
141
+ }
142
+ }
143
+
144
+ const size = 16;
145
+ const padding = 0.5;
146
+ const borderWidth = 1;
147
+ const sizeWithPadding = size + 2 * padding;
148
+
149
+ const styles = StyleSheet.create({
150
+ container: {
151
+ position: "relative",
152
+ display: "inline-block",
153
+ verticalAlign: "middle",
154
+ lineHeight: 0,
155
+ borderRadius: borderRadius,
156
+ width: sizeWithPadding,
157
+ height: sizeWithPadding,
158
+ flexShrink: 0,
159
+ },
160
+ focused: {
161
+ "::before": {
162
+ content: '""',
163
+ position: "absolute",
164
+ top: -2,
165
+ right: -2,
166
+ bottom: -2,
167
+ left: -2,
168
+ borderRadius: borderRadius + 2,
169
+ backgroundColor: "lightblue", // TODO(kevinb) get real focus ring
170
+ },
171
+ },
172
+ svg: {
173
+ position: "absolute",
174
+ left: 0,
175
+ top: 0,
176
+ },
177
+ checkbox: {
178
+ appearance: "none",
179
+ opacity: 0,
180
+ position: "absolute",
181
+ top: padding,
182
+ width: size,
183
+ height: size,
184
+ margin: 0,
185
+ outline: "none",
186
+ cursor: "pointer",
187
+ },
188
+ disabled: {
189
+ opacity: 0.5,
190
+ },
191
+ defaultCursor: {
192
+ cursor: "default",
193
+ },
194
+ });
@@ -0,0 +1,91 @@
1
+ // @flow
2
+ /**
3
+ * A component that displays its contents inside a device frame.
4
+ */
5
+
6
+ import {constants} from "@khanacademy/perseus";
7
+ import PropTypes from "prop-types";
8
+ import * as React from "react";
9
+
10
+ const SCREEN_SIZES = {
11
+ phone: {
12
+ width: 320,
13
+ height: 480,
14
+ framedWidth: 320,
15
+ },
16
+ tablet: {
17
+ width: 750,
18
+ height: 920,
19
+ framedWidth: 525,
20
+ },
21
+ desktop: {
22
+ width: 688,
23
+ height: 600,
24
+ framedWidth: 688,
25
+ },
26
+ };
27
+
28
+ type Props = $FlowFixMe;
29
+
30
+ class DeviceFramer extends React.Component<Props> {
31
+ static propTypes = {
32
+ children: PropTypes.element.isRequired,
33
+ deviceType: PropTypes.oneOf([
34
+ constants.devices.PHONE,
35
+ constants.devices.TABLET,
36
+ constants.devices.DESKTOP,
37
+ ]).isRequired,
38
+ // TODO(kevinb) rename to variableHeight
39
+ nochrome: PropTypes.bool,
40
+ };
41
+
42
+ render(): React.Node {
43
+ const deviceType = this.props.deviceType;
44
+
45
+ if (this.props.nochrome) {
46
+ // Render content inside a variable height iframe. Used on the
47
+ // "edit" table of the content editor. In this mode, PerseusFrame
48
+ // will draw the border and reserve space on the right for
49
+ // lint indicators.
50
+ return (
51
+ <div>
52
+ <div
53
+ key="screen"
54
+ style={{
55
+ width:
56
+ SCREEN_SIZES[deviceType].framedWidth +
57
+ 2 * constants.perseusFrameBorderWidth +
58
+ constants.lintGutterWidth,
59
+ }}
60
+ >
61
+ <div>{this.props.children}</div>
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+ const scale =
67
+ SCREEN_SIZES[deviceType].framedWidth /
68
+ SCREEN_SIZES[deviceType].width;
69
+
70
+ // In this mode we draw our own border and don't reserve
71
+ // space for a lint gutter.
72
+ const screenStyle = {
73
+ backgroundColor: "white",
74
+ color: "black",
75
+ textAlign: "left",
76
+ width: SCREEN_SIZES[deviceType].width,
77
+ height: SCREEN_SIZES[deviceType].height,
78
+ border: "solid 1px #CCC",
79
+ margin: 8,
80
+ zoom: scale,
81
+ };
82
+
83
+ return (
84
+ <div key="screen" className="screen" style={screenStyle}>
85
+ {this.props.children}
86
+ </div>
87
+ );
88
+ }
89
+ }
90
+
91
+ export default DeviceFramer;
@@ -0,0 +1,88 @@
1
+ // @noflow
2
+ /* This component makes its children a drag target. Example:
3
+ *
4
+ * <DragTarget onDrop={this.handleDrop}>Drag to me</DragTarget>
5
+ *
6
+ * ...
7
+ *
8
+ * handleDrop: function(e) {
9
+ * this.addImages(e.nativeEvent.dataTransfer.files);
10
+ * }
11
+ *
12
+ * Now "Drag to me" will be a drag target - when something is dragged over it,
13
+ * the element will become partially transparent as a visual indicator that
14
+ * it's a target.
15
+ */
16
+ // TODO(joel) - indicate before the hover is over the target that it's possible
17
+ // to drag into the target. This would (I think) require a high level handler -
18
+ // like on Perseus itself, waiting for onDragEnter, then passing down the
19
+ // event. Sounds like a pain. Possible workaround - create a div covering the
20
+ // entire page...
21
+ //
22
+ // Other extensions:
23
+ // * custom styles for global drag and dragOver
24
+ // * only respond to certain types of drags (only images for instance)!
25
+
26
+ import createReactClass from "create-react-class";
27
+ import PropTypes from "prop-types";
28
+ import * as React from "react";
29
+
30
+ const DragTarget = createReactClass({
31
+ propTypes: {
32
+ // All props not listed here are forwarded to the root element without
33
+ // modification.
34
+ onDrop: PropTypes.func.isRequired,
35
+ component: PropTypes.any, // component type
36
+ shouldDragHighlight: PropTypes.func,
37
+ style: PropTypes.any,
38
+ },
39
+ getDefaultProps: function () {
40
+ return {
41
+ component: "div",
42
+ shouldDragHighlight: () => true,
43
+ };
44
+ },
45
+ getInitialState: function () {
46
+ return {dragHover: false};
47
+ },
48
+ handleDrop: function (e) {
49
+ e.stopPropagation();
50
+ e.preventDefault();
51
+ this.setState({dragHover: false});
52
+ this.props.onDrop(e);
53
+ },
54
+ handleDragEnd: function () {
55
+ this.setState({dragHover: false});
56
+ },
57
+ handleDragOver: function (e) {
58
+ e.preventDefault();
59
+ },
60
+ handleDragLeave: function () {
61
+ this.setState({dragHover: false});
62
+ },
63
+ handleDragEnter: function (e) {
64
+ this.setState({dragHover: this.props.shouldDragHighlight(e)});
65
+ },
66
+ render: function () {
67
+ const opacity = this.state.dragHover ? {opacity: 0.3} : {};
68
+ const Component = this.props.component;
69
+
70
+ const forwardProps = Object.assign({}, this.props);
71
+ delete forwardProps.component;
72
+ delete forwardProps.shouldDragHighlight;
73
+
74
+ return (
75
+ <Component
76
+ {...forwardProps}
77
+ style={Object.assign({}, this.props.style, opacity)}
78
+ onDrop={this.handleDrop}
79
+ onDragEnd={this.handleDragEnd}
80
+ onDragOver={this.handleDragOver}
81
+ onDragEnter={this.handleDragEnter}
82
+ onDragLeave={this.handleDragLeave}
83
+ />
84
+ );
85
+ },
86
+ });
87
+
88
+ export default DragTarget;
@@ -0,0 +1,318 @@
1
+ // @flow
2
+ /**
3
+ * The Option is an item to be used in the Select components and the
4
+ * derivations such as MultiSelect, CheckList, and CheckBoxList. Also includes
5
+ * OptionGroup which is an internal component used in the dropdowns to house
6
+ * a list of options.
7
+ **/
8
+ import {components, globalStyles} from "@khanacademy/perseus";
9
+ import {css, StyleSheet} from "aphrodite";
10
+ import * as React from "react";
11
+ import ReactDOM from "react-dom";
12
+
13
+ import {focusWithChromeStickyFocusBugWorkaround} from "./util.js";
14
+
15
+ export type OptionRenderer = (
16
+ children: ?React.Element<any>,
17
+ value: string,
18
+ selected: boolean,
19
+ disabled?: boolean,
20
+ ) => React.Element<any>;
21
+
22
+ const {Icon} = components;
23
+ const {colors} = globalStyles;
24
+
25
+ const findAndFocusElement = (component: ?Element) => {
26
+ const DOMNode: ?(Element | Text) = ReactDOM.findDOMNode(component);
27
+ const button = ((DOMNode: any): HTMLInputElement);
28
+ // $FlowIgnore[method-unbinding]
29
+ if (button.focus) {
30
+ focusWithChromeStickyFocusBugWorkaround(button);
31
+ }
32
+ };
33
+
34
+ type OptionProps = {|
35
+ // The value to use when the option is selected
36
+ value: string,
37
+
38
+ // The display of the option
39
+ children?: React.Node,
40
+
41
+ // Is the current option selected?
42
+ selected?: boolean,
43
+
44
+ // Is the current option disabled?
45
+ disabled?: boolean,
46
+
47
+ // An event when an option is clicked
48
+ onClick?: () => void,
49
+
50
+ // An optional rendering function to render the details of the option
51
+ optionRenderer?: OptionRenderer,
52
+
53
+ // An optional function to call when the dropdown should close
54
+ // Only relevant if the Option is inside of a dropdown menu
55
+ onDropdownClose?: () => void,
56
+
57
+ // An optional value to override the focus styling of an option.
58
+ // Use caution when using this - keyboard users need to know what they're
59
+ // focused on in order to interact with the product!
60
+ hideFocusState?: boolean,
61
+
62
+ // An optional test id that can be used to identify this Option in automated tests.
63
+ testId?: string,
64
+
65
+ // Text to provide for assistive tech users
66
+ ariaLabel?: string,
67
+ |};
68
+
69
+ const check = `M10,3.8C10,4,9.9,4.2,9.8,4.3L5.1,8.9L4.3,9.8C4.2,9.9,4,10,3.8,10
70
+ S3.5,9.9,3.4,9.8L2.5,8.9L0.2,6.6C0.1,6.5,0,6.3,0,6.2s0.1-0.3,0.2-0.4
71
+ l0.9-0.9c0.1-0.1,0.3-0.2,0.4-0.2s0.3,0.1,0.4,0.2l1.9,1.9l4.2-4.2c0.1
72
+ -0.1,0.3-0.2,0.4-0.2c0.2,0,0.3,0.1,0.4,0.2l0.9,0.9C9.9,3.5,10,3.7,
73
+ 10,3.8z`;
74
+
75
+ export const optionHeight = 30;
76
+
77
+ class Option extends React.Component<OptionProps> {
78
+ node: HTMLDivElement;
79
+
80
+ handleKeyDown(event: any): void {
81
+ const {onDropdownClose} = this.props;
82
+ const pressedKey = event.key;
83
+ const focusedElement = event.target;
84
+
85
+ if (pressedKey === "ArrowDown" && focusedElement.nextSibling) {
86
+ event.preventDefault();
87
+ focusedElement.nextSibling.focus();
88
+ }
89
+ if (pressedKey === "ArrowUp" && focusedElement.previousSibling) {
90
+ event.preventDefault();
91
+ focusedElement.previousSibling.focus();
92
+ }
93
+ if (
94
+ pressedKey === "ArrowUp" &&
95
+ !focusedElement.previousSibling &&
96
+ onDropdownClose
97
+ ) {
98
+ event.preventDefault();
99
+ onDropdownClose();
100
+ }
101
+ if (
102
+ (pressedKey === "Escape" || pressedKey === "Tab") &&
103
+ onDropdownClose
104
+ ) {
105
+ onDropdownClose();
106
+ }
107
+ }
108
+
109
+ render(): React.Node {
110
+ const {
111
+ selected,
112
+ value,
113
+ onClick,
114
+ children,
115
+ optionRenderer,
116
+ disabled,
117
+ hideFocusState,
118
+ testId,
119
+ ariaLabel,
120
+ } = this.props;
121
+
122
+ return (
123
+ <button
124
+ // TODO(mdr): We found a new Flow error when upgrading:
125
+ // "node (null) This type is incompatible with this.node (HTMLDivElement)"
126
+ // $FlowFixMe[incompatible-type](0.52.0->0.53.0)
127
+ ref={(node) => (this.node = node)}
128
+ value={value}
129
+ role="menuitemradio"
130
+ aria-checked={selected}
131
+ className={css(
132
+ styles.notAButton,
133
+ disabled && styles.cursorDefault,
134
+ hideFocusState && styles.noFocus,
135
+ )}
136
+ onClick={(value: string) => {
137
+ if (!disabled && onClick) {
138
+ // TODO(mdr): We found a new Flow error here when upgrading
139
+ // from 0.35.0 to 0.52.0: "value (unused function
140
+ // argument)". This comment was automatically added to
141
+ // this file, to suppress the error for now. Please
142
+ // consider fixing!
143
+ // $FlowFixMe[extra-arg]
144
+ onClick(value);
145
+ }
146
+ }}
147
+ onKeyDown={(event: KeyboardEvent) => this.handleKeyDown(event)}
148
+ aria-disabled={disabled}
149
+ aria-label={ariaLabel}
150
+ data-test-id={testId}
151
+ >
152
+ {optionRenderer &&
153
+ optionRenderer(
154
+ /**
155
+ * This expects a `React.Element<>` but `children` is a
156
+ * `React.Node`. We can convert a `React.Node` to a
157
+ * `React.Element<>` by wrapping it in a fragment.
158
+ */
159
+ <>{children}</>,
160
+ value || "",
161
+ selected || false,
162
+ disabled,
163
+ )}
164
+
165
+ {!optionRenderer && (
166
+ <span
167
+ className={css(
168
+ styles.option,
169
+ selected && styles.optionSelected,
170
+ disabled && styles.optionDisabled,
171
+ )}
172
+ >
173
+ {children}
174
+ {selected && (
175
+ <span className={css(styles.check)}>
176
+ <Icon icon={check} />
177
+ </span>
178
+ )}
179
+ </span>
180
+ )}
181
+ </button>
182
+ );
183
+ }
184
+ }
185
+
186
+ // TODO(kevinb):
187
+ // - keep the the option group within the page bounds
188
+ // - do we really want to dismiss this on scroll
189
+ // - how does the drop down interact with tooltips? what's the z-index order?
190
+ class OptionGroup extends React.Component<{
191
+ // An event when an option is selected
192
+ onSelected: (value: string) => void,
193
+ // The list of options to display
194
+ children?: Array<React.Element<typeof Option>>,
195
+ // The currently selected options
196
+ selectedValues: Array<string>,
197
+ // An optional rendering function to render the details of the options
198
+ optionRenderer?: OptionRenderer,
199
+ // An override to skip the bit of top/bottom spacing around the group
200
+ noMargin?: boolean,
201
+ // An optional function to call when the dropdown should close
202
+ onDropdownClose?: () => void,
203
+ // An optional value to override the focus styling of an option.
204
+ // Use caution when using this - keyboard users need to know what
205
+ // they're focused on in order to interact with the product!
206
+ hideFocusState?: boolean,
207
+ ...
208
+ }> {
209
+ focusedElement: Element;
210
+
211
+ componentDidMount() {
212
+ if (this.focusedElement) {
213
+ findAndFocusElement(this.focusedElement);
214
+ }
215
+ }
216
+
217
+ render(): React.Element<"div"> {
218
+ const {
219
+ children,
220
+ onSelected,
221
+ selectedValues,
222
+ optionRenderer,
223
+ noMargin,
224
+ onDropdownClose,
225
+ hideFocusState,
226
+ } = this.props;
227
+
228
+ return (
229
+ <div
230
+ style={{top}}
231
+ className={css(
232
+ styles.optionGroup,
233
+ noMargin && styles.optionGroupNoMargin,
234
+ )}
235
+ >
236
+ {React.Children.map(children, (child, index) => {
237
+ const selected = selectedValues.includes(child.props.value);
238
+
239
+ const reference =
240
+ selected || index === 0
241
+ ? (node: Element) => (this.focusedElement = node)
242
+ : null;
243
+
244
+ return React.cloneElement(child, {
245
+ ...child.props,
246
+ key: index,
247
+ selected: selected,
248
+ onClick: () => onSelected(child.props.value),
249
+ optionRenderer: optionRenderer,
250
+ ref: reference,
251
+ onDropdownClose: onDropdownClose,
252
+ hideFocusState: hideFocusState,
253
+ });
254
+ })}
255
+ </div>
256
+ );
257
+ }
258
+ }
259
+
260
+ const styles = StyleSheet.create({
261
+ optionGroup: {
262
+ margin: "4px 0",
263
+ },
264
+ optionGroupNoMargin: {
265
+ margin: 0,
266
+ },
267
+ option: {
268
+ display: "flex",
269
+ flexDirection: "row",
270
+ alignItems: "center",
271
+ paddingLeft: 32,
272
+ paddingRight: 32,
273
+ height: optionHeight,
274
+ whiteSpace: "nowrap",
275
+ overflow: "hidden",
276
+ textOverflow: "ellipsis",
277
+ color: colors.gray17,
278
+ userSelect: "none",
279
+
280
+ ":hover": {
281
+ backgroundColor: colors.gray95,
282
+ },
283
+ },
284
+ optionSelected: {
285
+ backgroundColor: colors.gray95,
286
+ color: "#11ACCD",
287
+ },
288
+ optionDisabled: {
289
+ color: colors.gray76,
290
+ ":hover": {
291
+ backgroundColor: "transparent",
292
+ },
293
+ },
294
+ check: {
295
+ position: "absolute",
296
+ left: 11,
297
+ },
298
+ notAButton: {
299
+ backgroundColor: "transparent",
300
+ border: "none",
301
+ display: "block",
302
+ padding: 0,
303
+ margin: 0,
304
+ width: "100%",
305
+ // <button> uses user agent stylesheet over inherit so we need to set
306
+ // it explicitly.
307
+ font: "inherit",
308
+ },
309
+ noFocus: {
310
+ outline: "none",
311
+ },
312
+ cursorDefault: {
313
+ cursor: "default",
314
+ },
315
+ });
316
+
317
+ export {OptionGroup};
318
+ export default Option;