@khanacademy/wonder-blocks-form 2.2.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/dist/es/index.js +1100 -0
  3. package/dist/index.js +1419 -0
  4. package/dist/index.js.flow +2 -0
  5. package/docs.md +1 -0
  6. package/package.json +35 -0
  7. package/src/__tests__/__snapshots__/custom-snapshot.test.js.snap +1349 -0
  8. package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +6126 -0
  9. package/src/__tests__/custom-snapshot.test.js +66 -0
  10. package/src/__tests__/generated-snapshot.test.js +654 -0
  11. package/src/components/__tests__/checkbox-group.test.js +84 -0
  12. package/src/components/__tests__/field-heading.test.js +182 -0
  13. package/src/components/__tests__/labeled-text-field.test.js +442 -0
  14. package/src/components/__tests__/radio-group.test.js +84 -0
  15. package/src/components/__tests__/text-field.test.js +424 -0
  16. package/src/components/checkbox-core.js +201 -0
  17. package/src/components/checkbox-group.js +161 -0
  18. package/src/components/checkbox-group.md +200 -0
  19. package/src/components/checkbox.js +94 -0
  20. package/src/components/checkbox.md +134 -0
  21. package/src/components/choice-internal.js +206 -0
  22. package/src/components/choice.js +104 -0
  23. package/src/components/field-heading.js +157 -0
  24. package/src/components/field-heading.md +43 -0
  25. package/src/components/group-styles.js +35 -0
  26. package/src/components/labeled-text-field.js +265 -0
  27. package/src/components/labeled-text-field.md +535 -0
  28. package/src/components/labeled-text-field.stories.js +359 -0
  29. package/src/components/radio-core.js +176 -0
  30. package/src/components/radio-group.js +142 -0
  31. package/src/components/radio-group.md +129 -0
  32. package/src/components/radio.js +93 -0
  33. package/src/components/radio.md +26 -0
  34. package/src/components/text-field.js +326 -0
  35. package/src/components/text-field.md +770 -0
  36. package/src/components/text-field.stories.js +513 -0
  37. package/src/index.js +18 -0
  38. package/src/util/types.js +77 -0
@@ -0,0 +1,84 @@
1
+ //@flow
2
+ import * as React from "react";
3
+ import {mount} from "enzyme";
4
+
5
+ import RadioGroup from "../radio-group.js";
6
+ import Choice from "../choice.js";
7
+
8
+ describe("RadioGroup", () => {
9
+ let group;
10
+ const onChange = jest.fn();
11
+
12
+ beforeEach(() => {
13
+ group = mount(
14
+ <RadioGroup
15
+ label="Test"
16
+ description="test description"
17
+ groupName="test"
18
+ onChange={onChange}
19
+ selectedValue="a"
20
+ >
21
+ <Choice label="a" value="a" aria-labelledby="test-a" />
22
+ <Choice label="b" value="b" aria-labelledby="test-b" />
23
+ <Choice label="c" value="c" aria-labelledby="test-c" />
24
+ </RadioGroup>,
25
+ );
26
+ });
27
+
28
+ it("selects only one item at a time", () => {
29
+ const a = group.find(Choice).at(0);
30
+ const b = group.find(Choice).at(1);
31
+ const c = group.find(Choice).at(2);
32
+
33
+ // a starts off checked
34
+ expect(a.prop("checked")).toEqual(true);
35
+ expect(b.prop("checked")).toEqual(false);
36
+ expect(c.prop("checked")).toEqual(false);
37
+ });
38
+
39
+ it("changes selection when selectedValue changes", () => {
40
+ group.setProps({selectedValue: "b"});
41
+ const a = group.find(Choice).at(0);
42
+ const b = group.find(Choice).at(1);
43
+ const c = group.find(Choice).at(2);
44
+
45
+ // now b is checked
46
+ expect(a.prop("checked")).toEqual(false);
47
+ expect(b.prop("checked")).toEqual(true);
48
+ expect(c.prop("checked")).toEqual(false);
49
+ });
50
+
51
+ it("displays error state for all Choice children", () => {
52
+ group.setProps({errorMessage: "there's an error"});
53
+ const a = group.find(Choice).at(0);
54
+ const b = group.find(Choice).at(1);
55
+ const c = group.find(Choice).at(2);
56
+
57
+ expect(a.prop("error")).toEqual(true);
58
+ expect(b.prop("error")).toEqual(true);
59
+ expect(c.prop("error")).toEqual(true);
60
+ });
61
+
62
+ it("doesn't change when an already selected item is reselected", () => {
63
+ // a is already selected, onChange shouldn't be called
64
+ const a = group.find(Choice).at(0);
65
+ const aTarget = a.find("ClickableBehavior");
66
+ aTarget.simulate("click");
67
+ expect(onChange).toHaveBeenCalledTimes(0);
68
+
69
+ // now b is clicked, onChange should be called
70
+ const b = group.find(Choice).at(1);
71
+ const bTarget = b.find("ClickableBehavior");
72
+ bTarget.simulate("click");
73
+ expect(onChange).toHaveBeenCalledTimes(1);
74
+ });
75
+
76
+ it("checks that aria attributes have been added correctly", () => {
77
+ const a = group.find(Choice).at(0);
78
+ const b = group.find(Choice).at(1);
79
+ const c = group.find(Choice).at(2);
80
+ expect(a.find("input").prop("aria-labelledby")).toEqual("test-a");
81
+ expect(b.find("input").prop("aria-labelledby")).toEqual("test-b");
82
+ expect(c.find("input").prop("aria-labelledby")).toEqual("test-c");
83
+ });
84
+ });
@@ -0,0 +1,424 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import {mount} from "enzyme";
4
+
5
+ import TextField from "../text-field.js";
6
+
7
+ const wait = (delay: number = 0) =>
8
+ new Promise((resolve, reject) => {
9
+ // eslint-disable-next-line no-restricted-syntax
10
+ return setTimeout(resolve, delay);
11
+ });
12
+
13
+ describe("TextField", () => {
14
+ it("textfield is focused", () => {
15
+ // Arrange
16
+ const wrapper = mount(
17
+ <TextField id="tf-1" value="" onChange={() => {}} />,
18
+ );
19
+
20
+ // Act
21
+ wrapper.simulate("focus");
22
+
23
+ // Assert
24
+ expect(wrapper.find("TextFieldInternal")).toHaveState("focused", true);
25
+ });
26
+
27
+ it("onFocus is called after textfield is focused", () => {
28
+ // Arrange
29
+ const handleOnFocus = jest.fn(() => {});
30
+
31
+ const wrapper = mount(
32
+ <TextField
33
+ id={"tf-1"}
34
+ value="TextIsLongerThan8"
35
+ onChange={() => {}}
36
+ onFocus={handleOnFocus}
37
+ />,
38
+ );
39
+
40
+ // Act
41
+ wrapper.simulate("focus");
42
+
43
+ // Assert
44
+ expect(handleOnFocus).toHaveBeenCalled();
45
+ });
46
+
47
+ it("textfield is blurred", async () => {
48
+ // Arrange
49
+ const wrapper = mount(
50
+ <TextField id="tf-1" value="" onChange={() => {}} />,
51
+ );
52
+
53
+ // Act
54
+ wrapper.simulate("focus");
55
+ await wait(0);
56
+ wrapper.simulate("blur");
57
+
58
+ // Assert
59
+ expect(wrapper.find("TextFieldInternal")).toHaveState("focused", false);
60
+ });
61
+
62
+ it("onBlur is called after textfield is blurred", async () => {
63
+ // Arrange
64
+ const handleOnBlur = jest.fn(() => {});
65
+
66
+ const wrapper = mount(
67
+ <TextField
68
+ id={"tf-1"}
69
+ value="TextIsLongerThan8"
70
+ onChange={() => {}}
71
+ onBlur={handleOnBlur}
72
+ />,
73
+ );
74
+
75
+ // Act
76
+ wrapper.simulate("focus");
77
+ await wait(0);
78
+ wrapper.simulate("blur");
79
+
80
+ // Assert
81
+ expect(handleOnBlur).toHaveBeenCalled();
82
+ });
83
+
84
+ it("id prop is passed to the input element", () => {
85
+ // Arrange
86
+ const id: string = "tf-1";
87
+
88
+ // Act
89
+ const wrapper = mount(
90
+ <TextField id={id} value="" onChange={() => {}} />,
91
+ );
92
+
93
+ // Assert
94
+ const input = wrapper.find("input");
95
+ expect(input).toContainMatchingElement(`[id="${id}"]`);
96
+ });
97
+
98
+ it("type prop is passed to the input element", () => {
99
+ // Arrange
100
+ const type = "number";
101
+
102
+ // Act
103
+ const wrapper = mount(
104
+ <TextField id={"tf-1"} type={type} value="" onChange={() => {}} />,
105
+ );
106
+
107
+ // Assert
108
+ const input = wrapper.find("input");
109
+ expect(input).toContainMatchingElement(`[type="${type}"]`);
110
+ });
111
+
112
+ it("value prop is passed to the input element", () => {
113
+ // Arrange
114
+ const value = "Text";
115
+
116
+ // Act
117
+ const wrapper = mount(
118
+ <TextField id={"tf-1"} value={value} onChange={() => {}} />,
119
+ );
120
+
121
+ // Assert
122
+ const input = wrapper.find("input");
123
+ expect(input).toContainMatchingElement(`[value="${value}"]`);
124
+ });
125
+
126
+ it("disabled prop disables the input element", () => {
127
+ // Arrange
128
+ const wrapper = mount(
129
+ <TextField
130
+ id="tf-1"
131
+ value=""
132
+ onChange={() => {}}
133
+ disabled={true}
134
+ />,
135
+ );
136
+ const input = wrapper.find("input");
137
+
138
+ // Act
139
+
140
+ // Assert
141
+ expect(input).toBeDisabled();
142
+ });
143
+
144
+ it("onChange is called when value changes", () => {
145
+ // Arrange
146
+ const handleOnChange = jest.fn();
147
+
148
+ const wrapper = mount(
149
+ <TextField id={"tf-1"} value="Text" onChange={handleOnChange} />,
150
+ );
151
+
152
+ // Act
153
+ const newValue = "Test2";
154
+ wrapper.simulate("change", {target: {value: newValue}});
155
+
156
+ // Assert
157
+ expect(handleOnChange).toHaveBeenCalledWith(newValue);
158
+ });
159
+
160
+ it("validate is called when value changes", () => {
161
+ // Arrange
162
+ const handleValidate = jest.fn((value: string): ?string => {});
163
+
164
+ const wrapper = mount(
165
+ <TextField
166
+ id={"tf-1"}
167
+ value="Text"
168
+ validate={handleValidate}
169
+ onChange={() => {}}
170
+ />,
171
+ );
172
+
173
+ // Act
174
+ const newValue = "Text2";
175
+ wrapper.simulate("change", {target: {value: newValue}});
176
+
177
+ // Assert
178
+ expect(handleValidate).toHaveBeenCalledWith(newValue);
179
+ });
180
+
181
+ it("validate is given a valid input", () => {
182
+ // Arrange
183
+ const handleValidate = jest.fn((value: string): ?string => {
184
+ if (value.length < 8) {
185
+ return "Value is too short";
186
+ }
187
+ });
188
+
189
+ const wrapper = mount(
190
+ <TextField
191
+ id={"tf-1"}
192
+ value="TextIsLong"
193
+ validate={handleValidate}
194
+ onChange={() => {}}
195
+ />,
196
+ );
197
+
198
+ // Act
199
+ const newValue = "TextIsLongerThan8";
200
+ wrapper.simulate("change", {target: {value: newValue}});
201
+
202
+ // Assert
203
+ expect(handleValidate).toHaveReturnedWith(undefined);
204
+ });
205
+
206
+ it("validate is given an invalid input", () => {
207
+ // Arrange
208
+ const errorMessage = "Value is too short";
209
+ const handleValidate = jest.fn((value: string): ?string => {
210
+ if (value.length < 8) {
211
+ return errorMessage;
212
+ }
213
+ });
214
+
215
+ const wrapper = mount(
216
+ <TextField
217
+ id={"tf-1"}
218
+ value="TextIsLongerThan8"
219
+ validate={handleValidate}
220
+ onChange={() => {}}
221
+ />,
222
+ );
223
+
224
+ // Act
225
+ const newValue = "Text";
226
+ wrapper.simulate("change", {target: {value: newValue}});
227
+
228
+ // Assert
229
+ expect(handleValidate).toHaveReturnedWith(errorMessage);
230
+ });
231
+
232
+ it("onValidate is called after input validate", () => {
233
+ // Arrange
234
+ const errorMessage = "Value is too short";
235
+ const handleValidate = jest.fn((errorMessage: ?string) => {});
236
+ const validate = jest.fn((value: string): ?string => {
237
+ if (value.length < 8) {
238
+ return errorMessage;
239
+ }
240
+ });
241
+
242
+ const wrapper = mount(
243
+ <TextField
244
+ id={"tf-1"}
245
+ value="TextIsLongerThan8"
246
+ validate={validate}
247
+ onValidate={handleValidate}
248
+ onChange={() => {}}
249
+ />,
250
+ );
251
+
252
+ // Act
253
+ const newValue = "Text";
254
+ wrapper.simulate("change", {target: {value: newValue}});
255
+
256
+ // Assert
257
+ expect(handleValidate).toHaveBeenCalledWith(errorMessage);
258
+ });
259
+
260
+ it("onValidate is called on input's initial value", () => {
261
+ // Arrange
262
+ const errorMessage = "Value is too short";
263
+ const handleValidate = jest.fn((errorMessage: ?string) => {});
264
+ const validate = jest.fn((value: string): ?string => {
265
+ if (value.length < 8) {
266
+ return errorMessage;
267
+ }
268
+ });
269
+
270
+ // Act
271
+ mount(
272
+ <TextField
273
+ id={"tf-1"}
274
+ value="Short"
275
+ validate={validate}
276
+ onValidate={handleValidate}
277
+ onChange={() => {}}
278
+ />,
279
+ );
280
+
281
+ // Assert
282
+ expect(handleValidate).toHaveBeenCalledWith(errorMessage);
283
+ });
284
+
285
+ it("onKeyDown is called after keyboard key press", () => {
286
+ // Arrange
287
+ const handleOnKeyDown = jest.fn(
288
+ (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
289
+ return event.key;
290
+ },
291
+ );
292
+
293
+ const wrapper = mount(
294
+ <TextField
295
+ id={"tf-1"}
296
+ value="TextIsLongerThan8"
297
+ onChange={() => {}}
298
+ onKeyDown={handleOnKeyDown}
299
+ />,
300
+ );
301
+
302
+ // Act
303
+ const key = "Enter";
304
+ const input = wrapper.find("input");
305
+ input.simulate("keyDown", {key: key});
306
+
307
+ // Assert
308
+ expect(handleOnKeyDown).toHaveReturnedWith(key);
309
+ });
310
+
311
+ it("placeholder prop is passed to the input element", () => {
312
+ // Arrange
313
+ const placeholder = "Placeholder";
314
+
315
+ // Act
316
+ const wrapper = mount(
317
+ <TextField
318
+ id={"tf-1"}
319
+ value="Text"
320
+ placeholder={placeholder}
321
+ onChange={() => {}}
322
+ />,
323
+ );
324
+
325
+ // Assert
326
+ const input = wrapper.find("input");
327
+ expect(input).toContainMatchingElement(
328
+ `[placeholder="${placeholder}"]`,
329
+ );
330
+ });
331
+
332
+ it("required prop is passed to the input element", () => {
333
+ // Arrange
334
+ const wrapper = mount(
335
+ <TextField
336
+ id={"tf-1"}
337
+ value="Text"
338
+ onChange={() => {}}
339
+ required={true}
340
+ />,
341
+ );
342
+
343
+ // Act
344
+
345
+ // Assert
346
+ const input = wrapper.find("input");
347
+ expect(input).toContainMatchingElement("[required=true]");
348
+ });
349
+
350
+ it("testId is passed to the input element", () => {
351
+ // Arrange
352
+ const testId = "some-test-id";
353
+ const wrapper = mount(
354
+ <TextField
355
+ id={"tf-1"}
356
+ value="Text"
357
+ onChange={() => {}}
358
+ testId={testId}
359
+ />,
360
+ );
361
+
362
+ // Act
363
+
364
+ // Assert
365
+ const input = wrapper.find("input");
366
+ expect(input).toContainMatchingElement(`[data-test-id="${testId}"]`);
367
+ });
368
+
369
+ it("aria props are passed to the input element", () => {
370
+ // Arrange
371
+ const ariaLabel = "example-text-field";
372
+ const wrapper = mount(
373
+ <TextField
374
+ id={"tf-1"}
375
+ value="Text"
376
+ onChange={() => {}}
377
+ aria-label={ariaLabel}
378
+ />,
379
+ );
380
+
381
+ // Act
382
+
383
+ // Assert
384
+ const input = wrapper.find("input");
385
+ expect(input).toContainMatchingElement(`[aria-label="${ariaLabel}"]`);
386
+ });
387
+
388
+ it("readOnly prop is passed to the input element", async () => {
389
+ // Arrange
390
+
391
+ // Act
392
+ const wrapper = mount(
393
+ <TextField
394
+ id={"tf-1"}
395
+ value={"Text"}
396
+ onChange={() => {}}
397
+ readOnly={true}
398
+ />,
399
+ );
400
+
401
+ // Assert
402
+ const input = wrapper.find("input");
403
+ expect(input).toHaveProp("readOnly");
404
+ });
405
+
406
+ it("autoComplete prop is passed to the input element", async () => {
407
+ // Arrange
408
+ const autoComplete = "name";
409
+
410
+ // Act
411
+ const wrapper = mount(
412
+ <TextField
413
+ id={"tf-1"}
414
+ value={"Text"}
415
+ onChange={() => {}}
416
+ autoComplete={autoComplete}
417
+ />,
418
+ );
419
+
420
+ // Assert
421
+ const input = wrapper.find("input");
422
+ expect(input).toHaveProp("autoComplete", autoComplete);
423
+ });
424
+ });
@@ -0,0 +1,201 @@
1
+ // @flow
2
+
3
+ import * as React from "react";
4
+ import {StyleSheet} from "aphrodite";
5
+
6
+ import Color, {mix, fade} from "@khanacademy/wonder-blocks-color";
7
+ import {addStyle} from "@khanacademy/wonder-blocks-core";
8
+ import Icon from "@khanacademy/wonder-blocks-icon";
9
+
10
+ import type {IconAsset} from "@khanacademy/wonder-blocks-icon";
11
+
12
+ import type {ChoiceCoreProps} from "../util/types.js";
13
+
14
+ type Props = {|
15
+ ...ChoiceCoreProps,
16
+ hovered: boolean,
17
+ focused: boolean,
18
+ pressed: boolean,
19
+ waiting: boolean,
20
+ |};
21
+
22
+ const {blue, red, white, offWhite, offBlack16, offBlack32, offBlack50} = Color;
23
+
24
+ const StyledInput = addStyle("input");
25
+
26
+ const checkboxCheck: IconAsset = {
27
+ small:
28
+ "M11.263 4.324a1 1 0 1 1 1.474 1.352l-5.5 6a1 1 0 0 1-1.505-.036l-2.5-3a1 1 0 1 1 1.536-1.28L6.536 9.48l4.727-5.157z",
29
+ };
30
+
31
+ /**
32
+ * The internal stateless ☑️ Checkbox
33
+ */
34
+ export default class CheckboxCore extends React.Component<Props> {
35
+ handleChange: () => void = () => {
36
+ // Empty because change is handled by ClickableBehavior
37
+ return;
38
+ };
39
+
40
+ render(): React.Node {
41
+ const {
42
+ checked,
43
+ disabled,
44
+ error,
45
+ groupName,
46
+ id,
47
+ testId,
48
+ hovered,
49
+ focused,
50
+ pressed,
51
+ waiting: _,
52
+ ...sharedProps
53
+ } = this.props;
54
+
55
+ const stateStyles = _generateStyles(checked, error);
56
+
57
+ const defaultStyle = [
58
+ sharedStyles.inputReset,
59
+ sharedStyles.default,
60
+ stateStyles.default,
61
+ !disabled &&
62
+ (pressed
63
+ ? stateStyles.active
64
+ : (hovered || focused) && stateStyles.focus),
65
+ disabled && sharedStyles.disabled,
66
+ ];
67
+
68
+ const props = {
69
+ "data-test-id": testId,
70
+ };
71
+
72
+ return (
73
+ <React.Fragment>
74
+ <StyledInput
75
+ {...sharedProps}
76
+ type="checkbox"
77
+ aria-invalid={error}
78
+ checked={checked}
79
+ disabled={disabled}
80
+ id={id}
81
+ name={groupName}
82
+ // Need to specify because this is a controlled React form
83
+ // component, but we handle the click via ClickableBehavior
84
+ onChange={this.handleChange}
85
+ style={defaultStyle}
86
+ {...props}
87
+ />
88
+ {checked && (
89
+ <Icon
90
+ color={disabled ? offBlack32 : white}
91
+ icon={checkboxCheck}
92
+ size="small"
93
+ style={sharedStyles.checkIcon}
94
+ />
95
+ )}
96
+ </React.Fragment>
97
+ );
98
+ }
99
+ }
100
+
101
+ const size = 16;
102
+
103
+ const sharedStyles = StyleSheet.create({
104
+ // Reset the default styled input element
105
+ inputReset: {
106
+ appearance: "none",
107
+ WebkitAppearance: "none",
108
+ MozAppearance: "none",
109
+ },
110
+
111
+ default: {
112
+ height: size,
113
+ width: size,
114
+ minHeight: size,
115
+ minWidth: size,
116
+ margin: 0,
117
+ outline: "none",
118
+ boxSizing: "border-box",
119
+ borderStyle: "solid",
120
+ borderWidth: 1,
121
+ borderRadius: 3,
122
+ },
123
+
124
+ disabled: {
125
+ cursor: "auto",
126
+ backgroundColor: offWhite,
127
+ borderColor: offBlack16,
128
+ borderWidth: 1,
129
+ },
130
+
131
+ checkIcon: {
132
+ position: "absolute",
133
+ pointerEvents: "none",
134
+ },
135
+ });
136
+
137
+ const fadedBlue = mix(fade(blue, 0.16), white);
138
+ const activeBlue = mix(offBlack32, blue);
139
+ const fadedRed = mix(fade(red, 0.08), white);
140
+ const activeRed = mix(offBlack32, red);
141
+
142
+ const colors = {
143
+ default: {
144
+ faded: fadedBlue,
145
+ base: blue,
146
+ active: activeBlue,
147
+ },
148
+ error: {
149
+ faded: fadedRed,
150
+ base: red,
151
+ active: activeRed,
152
+ },
153
+ };
154
+
155
+ const styles = {};
156
+
157
+ const _generateStyles = (checked, error) => {
158
+ // "hash" the parameters
159
+ const styleKey = `${String(checked)}-${String(error)}`;
160
+ if (styles[styleKey]) {
161
+ return styles[styleKey];
162
+ }
163
+
164
+ const palette = error ? colors.error : colors.default;
165
+
166
+ let newStyles = {};
167
+ if (checked) {
168
+ newStyles = {
169
+ default: {
170
+ backgroundColor: palette.base,
171
+ borderWidth: 0,
172
+ },
173
+ focus: {
174
+ boxShadow: `0 0 0 1px ${white}, 0 0 0 3px ${palette.base}`,
175
+ },
176
+ active: {
177
+ boxShadow: `0 0 0 1px ${white}, 0 0 0 3px ${palette.active}`,
178
+ background: palette.active,
179
+ },
180
+ };
181
+ } else {
182
+ newStyles = {
183
+ default: {
184
+ backgroundColor: error ? fadedRed : white,
185
+ borderColor: error ? red : offBlack50,
186
+ },
187
+ focus: {
188
+ backgroundColor: error ? fadedRed : white,
189
+ borderColor: palette.base,
190
+ borderWidth: 2,
191
+ },
192
+ active: {
193
+ backgroundColor: palette.faded,
194
+ borderColor: error ? activeRed : blue,
195
+ borderWidth: 2,
196
+ },
197
+ };
198
+ }
199
+ styles[styleKey] = StyleSheet.create(newStyles);
200
+ return styles[styleKey];
201
+ };