@khanacademy/wonder-blocks-form 2.3.3 → 2.4.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/CHANGELOG.md +24 -0
- package/dist/es/index.js +33 -7
- package/dist/index.js +33 -7
- package/package.json +6 -6
- package/src/components/__tests__/labeled-text-field.test.js +139 -0
- package/src/components/__tests__/text-field.test.js +0 -18
- package/src/components/field-heading.js +20 -2
- package/src/components/labeled-text-field.js +28 -0
- package/src/components/labeled-text-field.stories.js +86 -0
- package/src/components/text-field.js +39 -7
- package/src/components/text-field.stories.js +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @khanacademy/wonder-blocks-form
|
|
2
2
|
|
|
3
|
+
## 2.4.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [246a921d]
|
|
8
|
+
- @khanacademy/wonder-blocks-core@4.3.0
|
|
9
|
+
- @khanacademy/wonder-blocks-clickable@2.2.5
|
|
10
|
+
- @khanacademy/wonder-blocks-icon@1.2.26
|
|
11
|
+
- @khanacademy/wonder-blocks-layout@1.4.8
|
|
12
|
+
- @khanacademy/wonder-blocks-typography@1.1.30
|
|
13
|
+
|
|
14
|
+
## 2.4.1
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies [166ecc97]
|
|
19
|
+
- @khanacademy/wonder-blocks-clickable@2.2.4
|
|
20
|
+
|
|
21
|
+
## 2.4.0
|
|
22
|
+
|
|
23
|
+
### Minor Changes
|
|
24
|
+
|
|
25
|
+
- af4f527c: LabeledTextField component now has a `required` prop that will mark is as required with an asterisk and provide validation
|
|
26
|
+
|
|
3
27
|
## 2.3.3
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
package/dist/es/index.js
CHANGED
|
@@ -716,7 +716,8 @@ class RadioGroup extends React.Component {
|
|
|
716
716
|
|
|
717
717
|
}
|
|
718
718
|
|
|
719
|
-
const _excluded = ["id", "type", "value", "disabled", "onKeyDown", "placeholder", "
|
|
719
|
+
const _excluded = ["id", "type", "value", "disabled", "onKeyDown", "placeholder", "light", "style", "testId", "readOnly", "autoComplete", "forwardedRef", "onFocus", "onBlur", "onValidate", "validate", "onChange", "required"];
|
|
720
|
+
const defaultErrorMessage = "This field is required.";
|
|
720
721
|
|
|
721
722
|
// TODO(WB-1081): Change class name back to TextField after Styleguidist is gone.
|
|
722
723
|
|
|
@@ -734,7 +735,8 @@ class TextFieldInternal extends React.Component {
|
|
|
734
735
|
this.maybeValidate = newValue => {
|
|
735
736
|
const {
|
|
736
737
|
validate,
|
|
737
|
-
onValidate
|
|
738
|
+
onValidate,
|
|
739
|
+
required
|
|
738
740
|
} = this.props;
|
|
739
741
|
|
|
740
742
|
if (validate) {
|
|
@@ -746,6 +748,16 @@ class TextFieldInternal extends React.Component {
|
|
|
746
748
|
onValidate(maybeError);
|
|
747
749
|
}
|
|
748
750
|
});
|
|
751
|
+
} else if (required) {
|
|
752
|
+
const requiredString = typeof required === "string" ? required : defaultErrorMessage;
|
|
753
|
+
const maybeError = newValue ? null : requiredString;
|
|
754
|
+
this.setState({
|
|
755
|
+
error: maybeError
|
|
756
|
+
}, () => {
|
|
757
|
+
if (onValidate) {
|
|
758
|
+
onValidate(maybeError);
|
|
759
|
+
}
|
|
760
|
+
});
|
|
749
761
|
}
|
|
750
762
|
};
|
|
751
763
|
|
|
@@ -784,14 +796,16 @@ class TextFieldInternal extends React.Component {
|
|
|
784
796
|
});
|
|
785
797
|
};
|
|
786
798
|
|
|
787
|
-
if (props.validate) {
|
|
799
|
+
if (props.validate && props.value !== "") {
|
|
788
800
|
// Ensures error is updated on unmounted server-side renders
|
|
789
801
|
this.state.error = props.validate(props.value) || null;
|
|
790
802
|
}
|
|
791
803
|
}
|
|
792
804
|
|
|
793
805
|
componentDidMount() {
|
|
794
|
-
|
|
806
|
+
if (this.props.value !== "") {
|
|
807
|
+
this.maybeValidate(this.props.value);
|
|
808
|
+
}
|
|
795
809
|
}
|
|
796
810
|
|
|
797
811
|
render() {
|
|
@@ -803,7 +817,6 @@ class TextFieldInternal extends React.Component {
|
|
|
803
817
|
disabled,
|
|
804
818
|
onKeyDown,
|
|
805
819
|
placeholder,
|
|
806
|
-
required,
|
|
807
820
|
light,
|
|
808
821
|
style,
|
|
809
822
|
testId,
|
|
@@ -825,7 +838,6 @@ class TextFieldInternal extends React.Component {
|
|
|
825
838
|
onKeyDown: onKeyDown,
|
|
826
839
|
onFocus: this.handleFocus,
|
|
827
840
|
onBlur: this.handleBlur,
|
|
828
|
-
required: required,
|
|
829
841
|
"data-test-id": testId,
|
|
830
842
|
readOnly: readOnly,
|
|
831
843
|
autoComplete: autoComplete,
|
|
@@ -894,23 +906,30 @@ const TextField = /*#__PURE__*/React.forwardRef((props, ref) => /*#__PURE__*/Rea
|
|
|
894
906
|
forwardedRef: ref
|
|
895
907
|
})));
|
|
896
908
|
|
|
909
|
+
const StyledSpan = addStyle("span");
|
|
897
910
|
/**
|
|
898
911
|
* A FieldHeading is an element that provides a label, description, and error element
|
|
899
912
|
* to present better context and hints to any type of form field component.
|
|
900
913
|
*/
|
|
914
|
+
|
|
901
915
|
class FieldHeading extends React.Component {
|
|
902
916
|
renderLabel() {
|
|
903
917
|
const {
|
|
904
918
|
label,
|
|
905
919
|
id,
|
|
920
|
+
required,
|
|
906
921
|
testId
|
|
907
922
|
} = this.props;
|
|
923
|
+
const requiredIcon = /*#__PURE__*/React.createElement(StyledSpan, {
|
|
924
|
+
style: styles.required,
|
|
925
|
+
"aria-hidden": true
|
|
926
|
+
}, " ", "*");
|
|
908
927
|
return /*#__PURE__*/React.createElement(React.Fragment, null, typeof label === "string" ? /*#__PURE__*/React.createElement(LabelMedium, {
|
|
909
928
|
style: styles.label,
|
|
910
929
|
tag: "label",
|
|
911
930
|
htmlFor: id && `${id}-field`,
|
|
912
931
|
testId: testId && `${testId}-label`
|
|
913
|
-
}, label) : label, /*#__PURE__*/React.createElement(Strut, {
|
|
932
|
+
}, label, required && requiredIcon) : label, /*#__PURE__*/React.createElement(Strut, {
|
|
914
933
|
size: Spacing.xxxSmall_4
|
|
915
934
|
}));
|
|
916
935
|
}
|
|
@@ -976,6 +995,9 @@ const styles = StyleSheet.create({
|
|
|
976
995
|
},
|
|
977
996
|
error: {
|
|
978
997
|
color: Color.red
|
|
998
|
+
},
|
|
999
|
+
required: {
|
|
1000
|
+
color: Color.red
|
|
979
1001
|
}
|
|
980
1002
|
});
|
|
981
1003
|
|
|
@@ -1042,6 +1064,7 @@ class LabeledTextFieldInternal extends React.Component {
|
|
|
1042
1064
|
description,
|
|
1043
1065
|
value,
|
|
1044
1066
|
disabled,
|
|
1067
|
+
required,
|
|
1045
1068
|
validate,
|
|
1046
1069
|
onChange,
|
|
1047
1070
|
onKeyDown,
|
|
@@ -1065,6 +1088,8 @@ class LabeledTextFieldInternal extends React.Component {
|
|
|
1065
1088
|
id: `${uniqueId}-field`,
|
|
1066
1089
|
"aria-describedby": ariaDescribedby ? ariaDescribedby : `${uniqueId}-error`,
|
|
1067
1090
|
"aria-invalid": this.state.error ? "true" : "false",
|
|
1091
|
+
"aria-required": required ? "true" : "false",
|
|
1092
|
+
required: required,
|
|
1068
1093
|
testId: testId && `${testId}-field`,
|
|
1069
1094
|
type: type,
|
|
1070
1095
|
value: value,
|
|
@@ -1083,6 +1108,7 @@ class LabeledTextFieldInternal extends React.Component {
|
|
|
1083
1108
|
}),
|
|
1084
1109
|
label: label,
|
|
1085
1110
|
description: description,
|
|
1111
|
+
required: !!required,
|
|
1086
1112
|
error: !this.state.focused && this.state.error || ""
|
|
1087
1113
|
}));
|
|
1088
1114
|
}
|
package/dist/index.js
CHANGED
|
@@ -254,6 +254,7 @@ function _extends() { _extends = Object.assign || function (target) { for (var i
|
|
|
254
254
|
|
|
255
255
|
|
|
256
256
|
|
|
257
|
+
const defaultErrorMessage = "This field is required.";
|
|
257
258
|
|
|
258
259
|
// TODO(WB-1081): Change class name back to TextField after Styleguidist is gone.
|
|
259
260
|
|
|
@@ -271,7 +272,8 @@ class TextFieldInternal extends react__WEBPACK_IMPORTED_MODULE_0__["Component"]
|
|
|
271
272
|
this.maybeValidate = newValue => {
|
|
272
273
|
const {
|
|
273
274
|
validate,
|
|
274
|
-
onValidate
|
|
275
|
+
onValidate,
|
|
276
|
+
required
|
|
275
277
|
} = this.props;
|
|
276
278
|
|
|
277
279
|
if (validate) {
|
|
@@ -283,6 +285,16 @@ class TextFieldInternal extends react__WEBPACK_IMPORTED_MODULE_0__["Component"]
|
|
|
283
285
|
onValidate(maybeError);
|
|
284
286
|
}
|
|
285
287
|
});
|
|
288
|
+
} else if (required) {
|
|
289
|
+
const requiredString = typeof required === "string" ? required : defaultErrorMessage;
|
|
290
|
+
const maybeError = newValue ? null : requiredString;
|
|
291
|
+
this.setState({
|
|
292
|
+
error: maybeError
|
|
293
|
+
}, () => {
|
|
294
|
+
if (onValidate) {
|
|
295
|
+
onValidate(maybeError);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
286
298
|
}
|
|
287
299
|
};
|
|
288
300
|
|
|
@@ -321,14 +333,16 @@ class TextFieldInternal extends react__WEBPACK_IMPORTED_MODULE_0__["Component"]
|
|
|
321
333
|
});
|
|
322
334
|
};
|
|
323
335
|
|
|
324
|
-
if (props.validate) {
|
|
336
|
+
if (props.validate && props.value !== "") {
|
|
325
337
|
// Ensures error is updated on unmounted server-side renders
|
|
326
338
|
this.state.error = props.validate(props.value) || null;
|
|
327
339
|
}
|
|
328
340
|
}
|
|
329
341
|
|
|
330
342
|
componentDidMount() {
|
|
331
|
-
|
|
343
|
+
if (this.props.value !== "") {
|
|
344
|
+
this.maybeValidate(this.props.value);
|
|
345
|
+
}
|
|
332
346
|
}
|
|
333
347
|
|
|
334
348
|
render() {
|
|
@@ -339,7 +353,6 @@ class TextFieldInternal extends react__WEBPACK_IMPORTED_MODULE_0__["Component"]
|
|
|
339
353
|
disabled,
|
|
340
354
|
onKeyDown,
|
|
341
355
|
placeholder,
|
|
342
|
-
required,
|
|
343
356
|
light,
|
|
344
357
|
style,
|
|
345
358
|
testId,
|
|
@@ -355,6 +368,7 @@ class TextFieldInternal extends react__WEBPACK_IMPORTED_MODULE_0__["Component"]
|
|
|
355
368
|
onValidate,
|
|
356
369
|
validate,
|
|
357
370
|
onChange,
|
|
371
|
+
required,
|
|
358
372
|
|
|
359
373
|
/* eslint-enable no-unused-vars */
|
|
360
374
|
// Should only include Aria related props
|
|
@@ -372,7 +386,6 @@ class TextFieldInternal extends react__WEBPACK_IMPORTED_MODULE_0__["Component"]
|
|
|
372
386
|
onKeyDown: onKeyDown,
|
|
373
387
|
onFocus: this.handleFocus,
|
|
374
388
|
onBlur: this.handleBlur,
|
|
375
|
-
required: required,
|
|
376
389
|
"data-test-id": testId,
|
|
377
390
|
readOnly: readOnly,
|
|
378
391
|
autoComplete: autoComplete,
|
|
@@ -926,6 +939,7 @@ class LabeledTextFieldInternal extends react__WEBPACK_IMPORTED_MODULE_0__["Compo
|
|
|
926
939
|
description,
|
|
927
940
|
value,
|
|
928
941
|
disabled,
|
|
942
|
+
required,
|
|
929
943
|
validate,
|
|
930
944
|
onChange,
|
|
931
945
|
onKeyDown,
|
|
@@ -949,6 +963,8 @@ class LabeledTextFieldInternal extends react__WEBPACK_IMPORTED_MODULE_0__["Compo
|
|
|
949
963
|
id: `${uniqueId}-field`,
|
|
950
964
|
"aria-describedby": ariaDescribedby ? ariaDescribedby : `${uniqueId}-error`,
|
|
951
965
|
"aria-invalid": this.state.error ? "true" : "false",
|
|
966
|
+
"aria-required": required ? "true" : "false",
|
|
967
|
+
required: required,
|
|
952
968
|
testId: testId && `${testId}-field`,
|
|
953
969
|
type: type,
|
|
954
970
|
value: value,
|
|
@@ -967,6 +983,7 @@ class LabeledTextFieldInternal extends react__WEBPACK_IMPORTED_MODULE_0__["Compo
|
|
|
967
983
|
}),
|
|
968
984
|
label: label,
|
|
969
985
|
description: description,
|
|
986
|
+
required: !!required,
|
|
970
987
|
error: !this.state.focused && this.state.error || ""
|
|
971
988
|
}));
|
|
972
989
|
}
|
|
@@ -1396,24 +1413,30 @@ const _generateStyles = (checked, error) => {
|
|
|
1396
1413
|
|
|
1397
1414
|
|
|
1398
1415
|
|
|
1399
|
-
|
|
1416
|
+
const StyledSpan = Object(_khanacademy_wonder_blocks_core__WEBPACK_IMPORTED_MODULE_2__["addStyle"])("span");
|
|
1400
1417
|
/**
|
|
1401
1418
|
* A FieldHeading is an element that provides a label, description, and error element
|
|
1402
1419
|
* to present better context and hints to any type of form field component.
|
|
1403
1420
|
*/
|
|
1421
|
+
|
|
1404
1422
|
class FieldHeading extends react__WEBPACK_IMPORTED_MODULE_0__["Component"] {
|
|
1405
1423
|
renderLabel() {
|
|
1406
1424
|
const {
|
|
1407
1425
|
label,
|
|
1408
1426
|
id,
|
|
1427
|
+
required,
|
|
1409
1428
|
testId
|
|
1410
1429
|
} = this.props;
|
|
1430
|
+
const requiredIcon = /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0__["createElement"](StyledSpan, {
|
|
1431
|
+
style: styles.required,
|
|
1432
|
+
"aria-hidden": true
|
|
1433
|
+
}, " ", "*");
|
|
1411
1434
|
return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0__["createElement"](react__WEBPACK_IMPORTED_MODULE_0__["Fragment"], null, typeof label === "string" ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0__["createElement"](_khanacademy_wonder_blocks_typography__WEBPACK_IMPORTED_MODULE_6__["LabelMedium"], {
|
|
1412
1435
|
style: styles.label,
|
|
1413
1436
|
tag: "label",
|
|
1414
1437
|
htmlFor: id && `${id}-field`,
|
|
1415
1438
|
testId: testId && `${testId}-label`
|
|
1416
|
-
}, label) : label, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0__["createElement"](_khanacademy_wonder_blocks_layout__WEBPACK_IMPORTED_MODULE_4__["Strut"], {
|
|
1439
|
+
}, label, required && requiredIcon) : label, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0__["createElement"](_khanacademy_wonder_blocks_layout__WEBPACK_IMPORTED_MODULE_4__["Strut"], {
|
|
1417
1440
|
size: _khanacademy_wonder_blocks_spacing__WEBPACK_IMPORTED_MODULE_5___default.a.xxxSmall_4
|
|
1418
1441
|
}));
|
|
1419
1442
|
}
|
|
@@ -1479,6 +1502,9 @@ const styles = aphrodite__WEBPACK_IMPORTED_MODULE_1__["StyleSheet"].create({
|
|
|
1479
1502
|
},
|
|
1480
1503
|
error: {
|
|
1481
1504
|
color: _khanacademy_wonder_blocks_color__WEBPACK_IMPORTED_MODULE_3___default.a.red
|
|
1505
|
+
},
|
|
1506
|
+
required: {
|
|
1507
|
+
color: _khanacademy_wonder_blocks_color__WEBPACK_IMPORTED_MODULE_3___default.a.red
|
|
1482
1508
|
}
|
|
1483
1509
|
});
|
|
1484
1510
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanacademy/wonder-blocks-form",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.2",
|
|
4
4
|
"design": "v1",
|
|
5
5
|
"description": "Form components for Wonder Blocks.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -16,13 +16,13 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@babel/runtime": "^7.16.3",
|
|
19
|
-
"@khanacademy/wonder-blocks-clickable": "^2.2.
|
|
19
|
+
"@khanacademy/wonder-blocks-clickable": "^2.2.5",
|
|
20
20
|
"@khanacademy/wonder-blocks-color": "^1.1.20",
|
|
21
|
-
"@khanacademy/wonder-blocks-core": "^4.
|
|
22
|
-
"@khanacademy/wonder-blocks-icon": "^1.2.
|
|
23
|
-
"@khanacademy/wonder-blocks-layout": "^1.4.
|
|
21
|
+
"@khanacademy/wonder-blocks-core": "^4.3.0",
|
|
22
|
+
"@khanacademy/wonder-blocks-icon": "^1.2.26",
|
|
23
|
+
"@khanacademy/wonder-blocks-layout": "^1.4.8",
|
|
24
24
|
"@khanacademy/wonder-blocks-spacing": "^3.0.5",
|
|
25
|
-
"@khanacademy/wonder-blocks-typography": "^1.1.
|
|
25
|
+
"@khanacademy/wonder-blocks-typography": "^1.1.30"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"aphrodite": "^1.2.5",
|
|
@@ -3,6 +3,7 @@ import * as React from "react";
|
|
|
3
3
|
import {mount} from "enzyme";
|
|
4
4
|
import "jest-enzyme";
|
|
5
5
|
import {render, screen} from "@testing-library/react";
|
|
6
|
+
import userEvent from "@testing-library/user-event";
|
|
6
7
|
|
|
7
8
|
import {StyleSheet} from "aphrodite";
|
|
8
9
|
import LabeledTextField from "../labeled-text-field.js";
|
|
@@ -485,3 +486,141 @@ describe("LabeledTextField", () => {
|
|
|
485
486
|
expect(textField).toHaveProp("autoComplete", autoComplete);
|
|
486
487
|
});
|
|
487
488
|
});
|
|
489
|
+
|
|
490
|
+
describe("Required LabeledTextField", () => {
|
|
491
|
+
test("has * when `required` prop is true", () => {
|
|
492
|
+
// Arrange
|
|
493
|
+
|
|
494
|
+
// Act
|
|
495
|
+
render(
|
|
496
|
+
<LabeledTextField
|
|
497
|
+
label="Label"
|
|
498
|
+
value=""
|
|
499
|
+
onChange={() => {}}
|
|
500
|
+
required={true}
|
|
501
|
+
/>,
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
// Assert
|
|
505
|
+
expect(screen.getByText("*")).toBeInTheDocument();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("does not have * when `required` prop is false", () => {
|
|
509
|
+
// Arrange
|
|
510
|
+
|
|
511
|
+
// Act
|
|
512
|
+
render(
|
|
513
|
+
<LabeledTextField
|
|
514
|
+
label="Label"
|
|
515
|
+
value=""
|
|
516
|
+
onChange={() => {}}
|
|
517
|
+
required={false}
|
|
518
|
+
/>,
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
// Assert
|
|
522
|
+
expect(screen.queryByText("*")).not.toBeInTheDocument();
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("aria-required is true when `required` prop is true", () => {
|
|
526
|
+
// Arrange
|
|
527
|
+
|
|
528
|
+
// Act
|
|
529
|
+
render(
|
|
530
|
+
<LabeledTextField
|
|
531
|
+
label="Label"
|
|
532
|
+
value=""
|
|
533
|
+
onChange={() => {}}
|
|
534
|
+
testId="foo-labeled-text-field"
|
|
535
|
+
required={true}
|
|
536
|
+
/>,
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
const textField = screen.getByTestId("foo-labeled-text-field-field");
|
|
540
|
+
|
|
541
|
+
// Assert
|
|
542
|
+
expect(textField).toHaveAttribute("aria-required", "true");
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("aria-required is false when `required` prop is false", () => {
|
|
546
|
+
// Arrange
|
|
547
|
+
|
|
548
|
+
// Act
|
|
549
|
+
render(
|
|
550
|
+
<LabeledTextField
|
|
551
|
+
label="Label"
|
|
552
|
+
value=""
|
|
553
|
+
onChange={() => {}}
|
|
554
|
+
testId="foo-labeled-text-field"
|
|
555
|
+
required={false}
|
|
556
|
+
/>,
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
const textField = screen.getByTestId("foo-labeled-text-field-field");
|
|
560
|
+
|
|
561
|
+
// Assert
|
|
562
|
+
expect(textField).toHaveAttribute("aria-required", "false");
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("displays the default message when the `required` prop is `true`", () => {
|
|
566
|
+
// Arrange
|
|
567
|
+
const TextFieldWrapper = () => {
|
|
568
|
+
const [value, setValue] = React.useState("");
|
|
569
|
+
return (
|
|
570
|
+
<LabeledTextField
|
|
571
|
+
label="Label"
|
|
572
|
+
value={value}
|
|
573
|
+
onChange={setValue}
|
|
574
|
+
required={true}
|
|
575
|
+
testId="test-labeled-text-field"
|
|
576
|
+
/>
|
|
577
|
+
);
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
render(<TextFieldWrapper />);
|
|
581
|
+
|
|
582
|
+
const textField = screen.getByTestId("test-labeled-text-field-field");
|
|
583
|
+
textField.focus();
|
|
584
|
+
userEvent.paste(textField, "a");
|
|
585
|
+
userEvent.clear(textField);
|
|
586
|
+
|
|
587
|
+
// Act
|
|
588
|
+
textField.blur();
|
|
589
|
+
|
|
590
|
+
// Assert
|
|
591
|
+
expect(screen.getByRole("alert")).toHaveTextContent(
|
|
592
|
+
"This field is required.",
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("displays the string passed into `required`", () => {
|
|
597
|
+
// Arrange
|
|
598
|
+
const errorMessage = "This is an example error message.";
|
|
599
|
+
|
|
600
|
+
const TextFieldWrapper = () => {
|
|
601
|
+
const [value, setValue] = React.useState("");
|
|
602
|
+
return (
|
|
603
|
+
<LabeledTextField
|
|
604
|
+
label="Label"
|
|
605
|
+
value={value}
|
|
606
|
+
onChange={setValue}
|
|
607
|
+
required={errorMessage}
|
|
608
|
+
testId="test-labeled-text-field"
|
|
609
|
+
/>
|
|
610
|
+
);
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
render(<TextFieldWrapper />);
|
|
614
|
+
|
|
615
|
+
const textField = screen.getByTestId("test-labeled-text-field-field");
|
|
616
|
+
textField.focus();
|
|
617
|
+
userEvent.paste(textField, "a");
|
|
618
|
+
userEvent.clear(textField);
|
|
619
|
+
|
|
620
|
+
// Act
|
|
621
|
+
textField.blur();
|
|
622
|
+
|
|
623
|
+
// Assert
|
|
624
|
+
expect(screen.getByRole("alert")).toHaveTextContent(errorMessage);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
@@ -330,24 +330,6 @@ describe("TextField", () => {
|
|
|
330
330
|
);
|
|
331
331
|
});
|
|
332
332
|
|
|
333
|
-
it("required prop is passed to the input element", () => {
|
|
334
|
-
// Arrange
|
|
335
|
-
const wrapper = mount(
|
|
336
|
-
<TextField
|
|
337
|
-
id={"tf-1"}
|
|
338
|
-
value="Text"
|
|
339
|
-
onChange={() => {}}
|
|
340
|
-
required={true}
|
|
341
|
-
/>,
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
// Act
|
|
345
|
-
|
|
346
|
-
// Assert
|
|
347
|
-
const input = wrapper.find("input");
|
|
348
|
-
expect(input).toContainMatchingElement("[required=true]");
|
|
349
|
-
});
|
|
350
|
-
|
|
351
333
|
it("testId is passed to the input element", () => {
|
|
352
334
|
// Arrange
|
|
353
335
|
const testId = "some-test-id";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import {StyleSheet} from "aphrodite";
|
|
4
4
|
|
|
5
|
-
import {View, type StyleType} from "@khanacademy/wonder-blocks-core";
|
|
5
|
+
import {View, addStyle, type StyleType} from "@khanacademy/wonder-blocks-core";
|
|
6
6
|
import Color from "@khanacademy/wonder-blocks-color";
|
|
7
7
|
import {Strut} from "@khanacademy/wonder-blocks-layout";
|
|
8
8
|
import Spacing from "@khanacademy/wonder-blocks-spacing";
|
|
@@ -28,6 +28,11 @@ type Props = {|
|
|
|
28
28
|
*/
|
|
29
29
|
description?: string | React.Element<Typography>,
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Whether this field is required to continue.
|
|
33
|
+
*/
|
|
34
|
+
required?: boolean,
|
|
35
|
+
|
|
31
36
|
/**
|
|
32
37
|
* The message for the error element.
|
|
33
38
|
*/
|
|
@@ -52,13 +57,22 @@ type Props = {|
|
|
|
52
57
|
testId?: string,
|
|
53
58
|
|};
|
|
54
59
|
|
|
60
|
+
const StyledSpan = addStyle("span");
|
|
61
|
+
|
|
55
62
|
/**
|
|
56
63
|
* A FieldHeading is an element that provides a label, description, and error element
|
|
57
64
|
* to present better context and hints to any type of form field component.
|
|
58
65
|
*/
|
|
59
66
|
export default class FieldHeading extends React.Component<Props> {
|
|
60
67
|
renderLabel(): React.Node {
|
|
61
|
-
const {label, id, testId} = this.props;
|
|
68
|
+
const {label, id, required, testId} = this.props;
|
|
69
|
+
|
|
70
|
+
const requiredIcon = (
|
|
71
|
+
<StyledSpan style={styles.required} aria-hidden={true}>
|
|
72
|
+
{" "}
|
|
73
|
+
*
|
|
74
|
+
</StyledSpan>
|
|
75
|
+
);
|
|
62
76
|
|
|
63
77
|
return (
|
|
64
78
|
<React.Fragment>
|
|
@@ -70,6 +84,7 @@ export default class FieldHeading extends React.Component<Props> {
|
|
|
70
84
|
testId={testId && `${testId}-label`}
|
|
71
85
|
>
|
|
72
86
|
{label}
|
|
87
|
+
{required && requiredIcon}
|
|
73
88
|
</LabelMedium>
|
|
74
89
|
) : (
|
|
75
90
|
label
|
|
@@ -154,4 +169,7 @@ const styles = StyleSheet.create({
|
|
|
154
169
|
error: {
|
|
155
170
|
color: Color.red,
|
|
156
171
|
},
|
|
172
|
+
required: {
|
|
173
|
+
color: Color.red,
|
|
174
|
+
},
|
|
157
175
|
});
|
|
@@ -41,6 +41,30 @@ type Props = {|
|
|
|
41
41
|
*/
|
|
42
42
|
disabled: boolean,
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Whether this field is required to to continue, or the error message to
|
|
46
|
+
* render if this field is left blank.
|
|
47
|
+
*
|
|
48
|
+
* This can be a boolean or a string.
|
|
49
|
+
*
|
|
50
|
+
* String:
|
|
51
|
+
* Please pass in a translated string to use as the error message that will
|
|
52
|
+
* render if the user leaves this field blank. If this field is required,
|
|
53
|
+
* and a string is not passed in, a default untranslated string will render
|
|
54
|
+
* upon error.
|
|
55
|
+
* Note: The string will not be used if a `validate` prop is passed in.
|
|
56
|
+
*
|
|
57
|
+
* Example message: i18n._("A password is required to log in.")
|
|
58
|
+
*
|
|
59
|
+
* Boolean:
|
|
60
|
+
* True/false indicating whether this field is required. Please do not pass
|
|
61
|
+
* in `true` if possible - pass in the error string instead.
|
|
62
|
+
* If `true` is passed, and a `validate` prop is not passed, that means
|
|
63
|
+
* there is no corresponding message and the default untranlsated message
|
|
64
|
+
* will be used.
|
|
65
|
+
*/
|
|
66
|
+
required?: boolean | string,
|
|
67
|
+
|
|
44
68
|
/**
|
|
45
69
|
* Identifies the element or elements that describes this text field.
|
|
46
70
|
*/
|
|
@@ -202,6 +226,7 @@ class LabeledTextFieldInternal extends React.Component<
|
|
|
202
226
|
description,
|
|
203
227
|
value,
|
|
204
228
|
disabled,
|
|
229
|
+
required,
|
|
205
230
|
validate,
|
|
206
231
|
onChange,
|
|
207
232
|
onKeyDown,
|
|
@@ -233,6 +258,8 @@ class LabeledTextFieldInternal extends React.Component<
|
|
|
233
258
|
aria-invalid={
|
|
234
259
|
this.state.error ? "true" : "false"
|
|
235
260
|
}
|
|
261
|
+
aria-required={required ? "true" : "false"}
|
|
262
|
+
required={required}
|
|
236
263
|
testId={testId && `${testId}-field`}
|
|
237
264
|
type={type}
|
|
238
265
|
value={value}
|
|
@@ -252,6 +279,7 @@ class LabeledTextFieldInternal extends React.Component<
|
|
|
252
279
|
}
|
|
253
280
|
label={label}
|
|
254
281
|
description={description}
|
|
282
|
+
required={!!required}
|
|
255
283
|
error={(!this.state.focused && this.state.error) || ""}
|
|
256
284
|
/>
|
|
257
285
|
)}
|
|
@@ -37,6 +37,55 @@ export const Text: StoryComponentType = () => {
|
|
|
37
37
|
);
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
export const RequiredWithDefaultText: StoryComponentType = () => {
|
|
41
|
+
const [value, setValue] = React.useState("");
|
|
42
|
+
|
|
43
|
+
const handleKeyDown = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
|
|
44
|
+
if (event.key === "Enter") {
|
|
45
|
+
event.currentTarget.blur();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<LabeledTextField
|
|
51
|
+
label="Name"
|
|
52
|
+
description="Please enter your name"
|
|
53
|
+
value={value}
|
|
54
|
+
onChange={(newValue) => setValue(newValue)}
|
|
55
|
+
onKeyDown={handleKeyDown}
|
|
56
|
+
required={true}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const RequiredWithSpecifiedText: StoryComponentType = () => {
|
|
62
|
+
const [value, setValue] = React.useState("");
|
|
63
|
+
|
|
64
|
+
const handleKeyDown = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
|
|
65
|
+
if (event.key === "Enter") {
|
|
66
|
+
event.currentTarget.blur();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<LabeledTextField
|
|
72
|
+
label="Name"
|
|
73
|
+
description="Please enter your name"
|
|
74
|
+
value={value}
|
|
75
|
+
onChange={(newValue) => setValue(newValue)}
|
|
76
|
+
onKeyDown={handleKeyDown}
|
|
77
|
+
required="This specific field is super required."
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
RequiredWithSpecifiedText.parameters = {
|
|
83
|
+
chromatic: {
|
|
84
|
+
// We have screenshots of other stories that cover this case.
|
|
85
|
+
disableSnapshot: true,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
40
89
|
export const Number: StoryComponentType = () => {
|
|
41
90
|
const [value, setValue] = React.useState("18");
|
|
42
91
|
|
|
@@ -121,6 +170,43 @@ export const Email: StoryComponentType = () => {
|
|
|
121
170
|
);
|
|
122
171
|
};
|
|
123
172
|
|
|
173
|
+
export const EmailRequired: StoryComponentType = () => {
|
|
174
|
+
const [value, setValue] = React.useState("");
|
|
175
|
+
|
|
176
|
+
const validate = (value: string) => {
|
|
177
|
+
const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/;
|
|
178
|
+
if (!emailRegex.test(value)) {
|
|
179
|
+
return "Please enter a valid email";
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const handleKeyDown = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
|
|
184
|
+
if (event.key === "Enter") {
|
|
185
|
+
event.currentTarget.blur();
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<LabeledTextField
|
|
191
|
+
label="Email"
|
|
192
|
+
type="email"
|
|
193
|
+
onChange={(newValue) => setValue(newValue)}
|
|
194
|
+
description="Please provide your personal email"
|
|
195
|
+
value={value}
|
|
196
|
+
validate={validate}
|
|
197
|
+
onKeyDown={handleKeyDown}
|
|
198
|
+
required={true}
|
|
199
|
+
/>
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
EmailRequired.parameters = {
|
|
204
|
+
chromatic: {
|
|
205
|
+
// We have screenshots of other stories that cover this case.
|
|
206
|
+
disableSnapshot: true,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
124
210
|
export const Telephone: StoryComponentType = () => {
|
|
125
211
|
const [value, setValue] = React.useState("123-456-7890");
|
|
126
212
|
|
|
@@ -12,6 +12,8 @@ export type TextFieldType = "text" | "password" | "email" | "number" | "tel";
|
|
|
12
12
|
|
|
13
13
|
type WithForwardRef = {|forwardedRef: React.Ref<"input">|};
|
|
14
14
|
|
|
15
|
+
const defaultErrorMessage = "This field is required.";
|
|
16
|
+
|
|
15
17
|
type Props = {|
|
|
16
18
|
...AriaProps,
|
|
17
19
|
|
|
@@ -72,9 +74,28 @@ type Props = {|
|
|
|
72
74
|
placeholder?: string,
|
|
73
75
|
|
|
74
76
|
/**
|
|
75
|
-
* Whether this
|
|
77
|
+
* Whether this field is required to to continue, or the error message to
|
|
78
|
+
* render if this field is left blank.
|
|
79
|
+
*
|
|
80
|
+
* This can be a boolean or a string.
|
|
81
|
+
*
|
|
82
|
+
* String:
|
|
83
|
+
* Please pass in a translated string to use as the error message that will
|
|
84
|
+
* render if the user leaves this field blank. If this field is required,
|
|
85
|
+
* and a string is not passed in, a default untranslated string will render
|
|
86
|
+
* upon error.
|
|
87
|
+
* Note: The string will not be used if a `validate` prop is passed in.
|
|
88
|
+
*
|
|
89
|
+
* Example message: i18n._("A password is required to log in.")
|
|
90
|
+
*
|
|
91
|
+
* Boolean:
|
|
92
|
+
* True/false indicating whether this field is required. Please do not pass
|
|
93
|
+
* in `true` if possible - pass in the error string instead.
|
|
94
|
+
* If `true` is passed, and a `validate` prop is not passed, that means
|
|
95
|
+
* there is no corresponding message and the default untranlsated message
|
|
96
|
+
* will be used.
|
|
76
97
|
*/
|
|
77
|
-
required?: boolean,
|
|
98
|
+
required?: boolean | string,
|
|
78
99
|
|
|
79
100
|
/**
|
|
80
101
|
* Change the default focus ring color to fit a dark background.
|
|
@@ -138,7 +159,7 @@ class TextFieldInternal extends React.Component<PropsWithForwardRef, State> {
|
|
|
138
159
|
|
|
139
160
|
constructor(props: PropsWithForwardRef) {
|
|
140
161
|
super(props);
|
|
141
|
-
if (props.validate) {
|
|
162
|
+
if (props.validate && props.value !== "") {
|
|
142
163
|
// Ensures error is updated on unmounted server-side renders
|
|
143
164
|
this.state.error = props.validate(props.value) || null;
|
|
144
165
|
}
|
|
@@ -150,11 +171,14 @@ class TextFieldInternal extends React.Component<PropsWithForwardRef, State> {
|
|
|
150
171
|
};
|
|
151
172
|
|
|
152
173
|
componentDidMount() {
|
|
153
|
-
|
|
174
|
+
if (this.props.value !== "") {
|
|
175
|
+
this.maybeValidate(this.props.value);
|
|
176
|
+
}
|
|
154
177
|
}
|
|
155
178
|
|
|
156
179
|
maybeValidate: (newValue: string) => void = (newValue) => {
|
|
157
|
-
const {validate, onValidate} = this.props;
|
|
180
|
+
const {validate, onValidate, required} = this.props;
|
|
181
|
+
|
|
158
182
|
if (validate) {
|
|
159
183
|
const maybeError = validate(newValue) || null;
|
|
160
184
|
this.setState({error: maybeError}, () => {
|
|
@@ -162,6 +186,15 @@ class TextFieldInternal extends React.Component<PropsWithForwardRef, State> {
|
|
|
162
186
|
onValidate(maybeError);
|
|
163
187
|
}
|
|
164
188
|
});
|
|
189
|
+
} else if (required) {
|
|
190
|
+
const requiredString =
|
|
191
|
+
typeof required === "string" ? required : defaultErrorMessage;
|
|
192
|
+
const maybeError = newValue ? null : requiredString;
|
|
193
|
+
this.setState({error: maybeError}, () => {
|
|
194
|
+
if (onValidate) {
|
|
195
|
+
onValidate(maybeError);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
165
198
|
}
|
|
166
199
|
};
|
|
167
200
|
|
|
@@ -204,7 +237,6 @@ class TextFieldInternal extends React.Component<PropsWithForwardRef, State> {
|
|
|
204
237
|
disabled,
|
|
205
238
|
onKeyDown,
|
|
206
239
|
placeholder,
|
|
207
|
-
required,
|
|
208
240
|
light,
|
|
209
241
|
style,
|
|
210
242
|
testId,
|
|
@@ -219,6 +251,7 @@ class TextFieldInternal extends React.Component<PropsWithForwardRef, State> {
|
|
|
219
251
|
onValidate,
|
|
220
252
|
validate,
|
|
221
253
|
onChange,
|
|
254
|
+
required,
|
|
222
255
|
/* eslint-enable no-unused-vars */
|
|
223
256
|
// Should only include Aria related props
|
|
224
257
|
...otherProps
|
|
@@ -249,7 +282,6 @@ class TextFieldInternal extends React.Component<PropsWithForwardRef, State> {
|
|
|
249
282
|
onKeyDown={onKeyDown}
|
|
250
283
|
onFocus={this.handleFocus}
|
|
251
284
|
onBlur={this.handleBlur}
|
|
252
|
-
required={required}
|
|
253
285
|
data-test-id={testId}
|
|
254
286
|
readOnly={readOnly}
|
|
255
287
|
autoComplete={autoComplete}
|
|
@@ -40,6 +40,31 @@ export const Text: StoryComponentType = () => {
|
|
|
40
40
|
);
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
export const Required: StoryComponentType = () => {
|
|
44
|
+
const [value, setValue] = React.useState("");
|
|
45
|
+
|
|
46
|
+
const handleChange = (newValue: string) => {
|
|
47
|
+
setValue(newValue);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleKeyDown = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
|
|
51
|
+
if (event.key === "Enter") {
|
|
52
|
+
event.currentTarget.blur();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<TextField
|
|
58
|
+
id="tf-1"
|
|
59
|
+
type="text"
|
|
60
|
+
value={value}
|
|
61
|
+
onChange={handleChange}
|
|
62
|
+
onKeyDown={handleKeyDown}
|
|
63
|
+
required={true}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
43
68
|
export const Number: StoryComponentType = () => {
|
|
44
69
|
const [value, setValue] = React.useState("12345");
|
|
45
70
|
|