@khanacademy/wonder-blocks-form 2.3.3 → 2.4.0

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @khanacademy/wonder-blocks-form
2
2
 
3
+ ## 2.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - af4f527c: LabeledTextField component now has a `required` prop that will mark is as required with an asterisk and provide validation
8
+
3
9
  ## 2.3.3
4
10
 
5
11
  ### 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", "required", "light", "style", "testId", "readOnly", "autoComplete", "forwardedRef", "onFocus", "onBlur", "onValidate", "validate", "onChange"];
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
- this.maybeValidate(this.props.value);
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
- this.maybeValidate(this.props.value);
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.3",
3
+ "version": "2.4.0",
4
4
  "design": "v1",
5
5
  "description": "Form components for Wonder Blocks.",
6
6
  "main": "dist/index.js",
@@ -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 component is required.
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
- this.maybeValidate(this.props.value);
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