@khanacademy/wonder-blocks-form 4.0.8 → 4.1.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.
@@ -5,32 +5,24 @@ import CheckboxCore from "../components/checkbox-core";
5
5
  import RadioCore from "../components/radio-core";
6
6
 
7
7
  const states = ["default", "error", "disabled"];
8
- const clickableStates = ["default", "hovered", "pressed"];
9
- const checkedStates = [false, true];
8
+ const checkedStates = [false, true, null];
10
9
 
11
10
  describe("CheckboxCore", () => {
12
11
  states.forEach((state: any) => {
13
- clickableStates.forEach((clickableState: any) => {
14
- checkedStates.forEach((checked: any) => {
15
- test(`type:${state} state:${clickableState} checked:${String(
16
- checked,
17
- )}`, () => {
18
- const disabled = state === "disabled";
19
- const tree = renderer
20
- .create(
21
- <CheckboxCore
22
- checked={checked}
23
- disabled={disabled}
24
- error={state === "error"}
25
- hovered={clickableState === "hovered"}
26
- pressed={clickableState === "pressed"}
27
- focused={clickableState === "focused"}
28
- waiting={false}
29
- />,
30
- )
31
- .toJSON();
32
- expect(tree).toMatchSnapshot();
33
- });
12
+ checkedStates.forEach((checked: any) => {
13
+ test(`type:${state} checked:${String(checked)}`, () => {
14
+ const disabled = state === "disabled";
15
+ const tree = renderer
16
+ .create(
17
+ <CheckboxCore
18
+ checked={checked}
19
+ disabled={disabled}
20
+ error={state === "error"}
21
+ onClick={() => {}}
22
+ />,
23
+ )
24
+ .toJSON();
25
+ expect(tree).toMatchSnapshot();
34
26
  });
35
27
  });
36
28
  });
@@ -38,27 +30,20 @@ describe("CheckboxCore", () => {
38
30
 
39
31
  describe("RadioCore", () => {
40
32
  states.forEach((state: any) => {
41
- clickableStates.forEach((clickableState: any) => {
42
- checkedStates.forEach((checked: any) => {
43
- test(`type:${state} state:${clickableState} checked:${String(
44
- checked,
45
- )}`, () => {
46
- const disabled = state === "disabled";
47
- const tree = renderer
48
- .create(
49
- <RadioCore
50
- checked={checked}
51
- disabled={disabled}
52
- error={state === "error"}
53
- hovered={clickableState === "hovered"}
54
- pressed={clickableState === "pressed"}
55
- focused={clickableState === "focused"}
56
- waiting={false}
57
- />,
58
- )
59
- .toJSON();
60
- expect(tree).toMatchSnapshot();
61
- });
33
+ checkedStates.forEach((checked: any) => {
34
+ test(`type:${state} checked:${String(checked)}`, () => {
35
+ const disabled = state === "disabled";
36
+ const tree = renderer
37
+ .create(
38
+ <RadioCore
39
+ checked={checked}
40
+ disabled={disabled}
41
+ error={state === "error"}
42
+ onClick={() => {}}
43
+ />,
44
+ )
45
+ .toJSON();
46
+ expect(tree).toMatchSnapshot();
62
47
  });
63
48
  });
64
49
  });
@@ -0,0 +1,84 @@
1
+ //@flow
2
+ import * as React from "react";
3
+ import {render, screen} from "@testing-library/react";
4
+
5
+ import Checkbox from "../checkbox";
6
+
7
+ describe("Checkbox", () => {
8
+ test("uses the ID prop when it is specified", () => {
9
+ // Arrange, Act
10
+ render(
11
+ <Checkbox
12
+ id="specified-checkbox-id"
13
+ label="Receive assignment reminders for Algebra"
14
+ description="You will receive a reminder 24 hours before each deadline"
15
+ checked={false}
16
+ onChange={() => {}}
17
+ />,
18
+ );
19
+
20
+ const checkbox = screen.getByRole("checkbox");
21
+
22
+ // Assert
23
+ expect(checkbox).toHaveAttribute("id", "specified-checkbox-id");
24
+ });
25
+
26
+ test("provides a unique ID when the ID prop is not specified", () => {
27
+ // Arrange, Act
28
+ render(
29
+ <Checkbox
30
+ label="Receive assignment reminders for Algebra"
31
+ description="You will receive a reminder 24 hours before each deadline"
32
+ checked={false}
33
+ onChange={() => {}}
34
+ />,
35
+ );
36
+
37
+ const checkbox = screen.getByRole("checkbox");
38
+
39
+ // Assert
40
+ expect(checkbox).toHaveAttribute("id", "uid-choice-1-main");
41
+ });
42
+
43
+ test("clicking the checkbox triggers `onChange`", () => {
44
+ // Arrange
45
+ const onChangeSpy = jest.fn();
46
+ render(
47
+ <Checkbox
48
+ label="Receive assignment reminders for Algebra"
49
+ description="You will receive a reminder 24 hours before each deadline"
50
+ checked={false}
51
+ onChange={onChangeSpy}
52
+ />,
53
+ );
54
+
55
+ // Act
56
+ const checkbox = screen.getByRole("checkbox");
57
+ checkbox.click();
58
+
59
+ // Assert
60
+ expect(onChangeSpy).toHaveBeenCalled();
61
+ });
62
+
63
+ test("clicks the label triggers `onChange`", () => {
64
+ // Arrange
65
+ const onChangeSpy = jest.fn();
66
+ render(
67
+ <Checkbox
68
+ label="Receive assignment reminders for Algebra"
69
+ description="You will receive a reminder 24 hours before each deadline"
70
+ checked={false}
71
+ onChange={onChangeSpy}
72
+ />,
73
+ );
74
+
75
+ // Act
76
+ const checkboxLabel = screen.getByText(
77
+ "Receive assignment reminders for Algebra",
78
+ );
79
+ checkboxLabel.click();
80
+
81
+ // Assert
82
+ expect(onChangeSpy).toHaveBeenCalled();
83
+ });
84
+ });
@@ -3,6 +3,7 @@ import {render, screen, fireEvent} from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
 
5
5
  import {StyleSheet} from "aphrodite";
6
+ import Color from "@khanacademy/wonder-blocks-color";
6
7
  import LabeledTextField from "../labeled-text-field";
7
8
 
8
9
  describe("LabeledTextField", () => {
@@ -392,7 +393,9 @@ describe("LabeledTextField", () => {
392
393
  textField.focus();
393
394
 
394
395
  // Assert
395
- expect(textField.getAttribute("class")).toMatch(/light/i);
396
+ expect(textField).toHaveStyle({
397
+ boxShadow: `0px 0px 0px 1px ${Color.blue}, 0px 0px 0px 2px ${Color.white}`,
398
+ });
396
399
  });
397
400
 
398
401
  it("style prop is passed to fieldheading", async () => {
@@ -6,28 +6,54 @@ import {addStyle} from "@khanacademy/wonder-blocks-core";
6
6
  import Icon from "@khanacademy/wonder-blocks-icon";
7
7
 
8
8
  import type {IconAsset} from "@khanacademy/wonder-blocks-icon";
9
-
10
- import type {ChoiceCoreProps} from "../util/types";
11
-
12
- type Props = ChoiceCoreProps & {
13
- hovered: boolean;
14
- focused: boolean;
15
- pressed: boolean;
16
- waiting: boolean;
17
- };
9
+ import type {ChoiceCoreProps, Checked} from "../util/types";
10
+
11
+ // `AriaChecked` and `mapCheckedToAriaChecked()` are used to convert the
12
+ // `checked` prop value to a value that a screen reader can understand via the
13
+ // `aria-checked` attribute
14
+ type AriaChecked = "true" | "false" | "mixed";
15
+
16
+ function mapCheckedToAriaChecked(value: Checked): AriaChecked {
17
+ switch (value) {
18
+ case true:
19
+ return "true";
20
+ case false:
21
+ return "false";
22
+ default:
23
+ return "mixed";
24
+ }
25
+ }
18
26
 
19
27
  const {blue, red, white, offWhite, offBlack16, offBlack32, offBlack50} = Color;
20
28
 
21
29
  const StyledInput = addStyle("input");
22
30
 
23
- const checkboxCheck: IconAsset = {
31
+ const checkPath: IconAsset = {
24
32
  small: "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",
25
33
  };
26
34
 
35
+ const indeterminatePath: IconAsset = {
36
+ small: "M3 8C3 7.44772 3.44772 7 4 7H12C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9H4C3.44772 9 3 8.55228 3 8Z",
37
+ };
38
+
27
39
  /**
28
40
  * The internal stateless ☑️ Checkbox
29
41
  */
30
- export default class CheckboxCore extends React.Component<Props> {
42
+ export default class CheckboxCore extends React.Component<ChoiceCoreProps> {
43
+ componentDidMount(): void {
44
+ if (this.props.checked == null && this.inputRef.current != null) {
45
+ this.inputRef.current.indeterminate = true;
46
+ }
47
+ }
48
+
49
+ componentDidUpdate(prevProps: Readonly<ChoiceCoreProps>): void {
50
+ if (this.inputRef.current != null) {
51
+ this.inputRef.current.indeterminate = this.props.checked == null;
52
+ }
53
+ }
54
+
55
+ inputRef: React.RefObject<HTMLInputElement> = React.createRef();
56
+
31
57
  handleChange: () => void = () => {
32
58
  // Empty because change is handled by ClickableBehavior
33
59
  return;
@@ -41,10 +67,6 @@ export default class CheckboxCore extends React.Component<Props> {
41
67
  groupName,
42
68
  id,
43
69
  testId,
44
- hovered,
45
- focused,
46
- pressed,
47
- waiting: _,
48
70
  ...sharedProps
49
71
  } = this.props;
50
72
 
@@ -53,11 +75,7 @@ export default class CheckboxCore extends React.Component<Props> {
53
75
  const defaultStyle = [
54
76
  sharedStyles.inputReset,
55
77
  sharedStyles.default,
56
- stateStyles.default,
57
- !disabled &&
58
- (pressed
59
- ? stateStyles.active
60
- : (hovered || focused) && stateStyles.focus),
78
+ !disabled && stateStyles.default,
61
79
  disabled && sharedStyles.disabled,
62
80
  ];
63
81
 
@@ -65,13 +83,26 @@ export default class CheckboxCore extends React.Component<Props> {
65
83
  "data-test-id": testId,
66
84
  } as const;
67
85
 
86
+ const checkboxIcon = (
87
+ <Icon
88
+ color={disabled ? offBlack32 : white}
89
+ icon={checked ? checkPath : indeterminatePath}
90
+ size="small"
91
+ style={sharedStyles.checkboxIcon}
92
+ />
93
+ );
94
+
95
+ const ariaChecked = mapCheckedToAriaChecked(checked);
96
+
68
97
  return (
69
98
  <React.Fragment>
70
99
  <StyledInput
71
100
  {...sharedProps}
101
+ ref={this.inputRef}
72
102
  type="checkbox"
103
+ aria-checked={ariaChecked}
73
104
  aria-invalid={error}
74
- checked={checked}
105
+ checked={checked ?? undefined}
75
106
  disabled={disabled}
76
107
  id={id}
77
108
  name={groupName}
@@ -81,14 +112,7 @@ export default class CheckboxCore extends React.Component<Props> {
81
112
  style={defaultStyle}
82
113
  {...props}
83
114
  />
84
- {checked && (
85
- <Icon
86
- color={disabled ? offBlack32 : white}
87
- icon={checkboxCheck}
88
- size="small"
89
- style={sharedStyles.checkIcon}
90
- />
91
- )}
115
+ {checked || checked == null ? checkboxIcon : <></>}
92
116
  </React.Fragment>
93
117
  );
94
118
  }
@@ -124,7 +148,7 @@ const sharedStyles = StyleSheet.create({
124
148
  borderWidth: 1,
125
149
  },
126
150
 
127
- checkIcon: {
151
+ checkboxIcon: {
128
152
  position: "absolute",
129
153
  pointerEvents: "none",
130
154
  },
@@ -150,7 +174,7 @@ const colors = {
150
174
 
151
175
  const styles: Record<string, any> = {};
152
176
 
153
- const _generateStyles = (checked: boolean, error: boolean) => {
177
+ const _generateStyles = (checked: Checked, error: boolean) => {
154
178
  // "hash" the parameters
155
179
  const styleKey = `${String(checked)}-${String(error)}`;
156
180
  if (styles[styleKey]) {
@@ -160,18 +184,26 @@ const _generateStyles = (checked: boolean, error: boolean) => {
160
184
  const palette = error ? colors.error : colors.default;
161
185
 
162
186
  let newStyles: Record<string, any> = {};
163
- if (checked) {
187
+ if (checked || checked == null) {
164
188
  newStyles = {
165
189
  default: {
166
190
  backgroundColor: palette.base,
167
191
  borderWidth: 0,
168
- },
169
- focus: {
170
- boxShadow: `0 0 0 1px ${white}, 0 0 0 3px ${palette.base}`,
171
- },
172
- active: {
173
- boxShadow: `0 0 0 1px ${white}, 0 0 0 3px ${palette.active}`,
174
- background: palette.active,
192
+
193
+ // Focus and hover have the same style. Focus style only shows
194
+ // up with keyboard navigation.
195
+ ":focus-visible": {
196
+ boxShadow: `0 0 0 1px ${white}, 0 0 0 3px ${palette.base}`,
197
+ },
198
+
199
+ ":hover": {
200
+ boxShadow: `0 0 0 1px ${white}, 0 0 0 3px ${palette.base}`,
201
+ },
202
+
203
+ ":active": {
204
+ boxShadow: `0 0 0 1px ${white}, 0 0 0 3px ${palette.active}`,
205
+ background: palette.active,
206
+ },
175
207
  },
176
208
  };
177
209
  } else {
@@ -179,16 +211,26 @@ const _generateStyles = (checked: boolean, error: boolean) => {
179
211
  default: {
180
212
  backgroundColor: error ? fadedRed : white,
181
213
  borderColor: error ? red : offBlack50,
182
- },
183
- focus: {
184
- backgroundColor: error ? fadedRed : white,
185
- borderColor: palette.base,
186
- borderWidth: 2,
187
- },
188
- active: {
189
- backgroundColor: palette.faded,
190
- borderColor: error ? activeRed : blue,
191
- borderWidth: 2,
214
+
215
+ // Focus and hover have the same style. Focus style only shows
216
+ // up with keyboard navigation.
217
+ ":focus-visible": {
218
+ backgroundColor: error ? fadedRed : white,
219
+ borderColor: palette.base,
220
+ borderWidth: 2,
221
+ },
222
+
223
+ ":hover": {
224
+ backgroundColor: error ? fadedRed : white,
225
+ borderColor: palette.base,
226
+ borderWidth: 2,
227
+ },
228
+
229
+ ":active": {
230
+ backgroundColor: palette.faded,
231
+ borderColor: error ? activeRed : blue,
232
+ borderWidth: 2,
233
+ },
192
234
  },
193
235
  };
194
236
  }
@@ -59,8 +59,8 @@ type CheckboxGroupProps = {
59
59
  testId?: string;
60
60
  };
61
61
 
62
- const StyledFieldset = addStyle<"fieldset">("fieldset");
63
- const StyledLegend = addStyle<"legend">("legend");
62
+ const StyledFieldset = addStyle("fieldset");
63
+ const StyledLegend = addStyle("legend");
64
64
 
65
65
  /**
66
66
  * A checkbox group allows multiple selection. This component auto-populates
@@ -1,14 +1,16 @@
1
1
  import * as React from "react";
2
2
 
3
3
  import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
4
+ import type {Checked} from "../util/types";
5
+
4
6
  import ChoiceInternal from "./choice-internal";
5
7
 
6
8
  // Keep synced with ChoiceComponentProps in ../util/types.js
7
9
  type ChoiceComponentProps = AriaProps & {
8
10
  /**
9
- * Whether this component is checked
11
+ * Whether this component is checked or indeterminate
10
12
  */
11
- checked: boolean;
13
+ checked: Checked;
12
14
  /**
13
15
  * Whether this component is disabled
14
16
  */
@@ -3,7 +3,6 @@ import {StyleSheet} from "aphrodite";
3
3
 
4
4
  import Color from "@khanacademy/wonder-blocks-color";
5
5
  import {View, UniqueIDProvider} from "@khanacademy/wonder-blocks-core";
6
- import {getClickableBehavior} from "@khanacademy/wonder-blocks-clickable";
7
6
  import {Strut} from "@khanacademy/wonder-blocks-layout";
8
7
  import Spacing from "@khanacademy/wonder-blocks-spacing";
9
8
  import {LabelMedium, LabelSmall} from "@khanacademy/wonder-blocks-typography";
@@ -13,7 +12,7 @@ import RadioCore from "./radio-core";
13
12
 
14
13
  type Props = AriaProps & {
15
14
  /** Whether this choice is checked. */
16
- checked: boolean;
15
+ checked: boolean | null | undefined;
17
16
  /** Whether this choice option is disabled. */
18
17
  disabled: boolean;
19
18
  /** Whether this choice is in error mode. */
@@ -50,7 +49,6 @@ type Props = AriaProps & {
50
49
  };
51
50
 
52
51
  type DefaultProps = {
53
- checked: Props["checked"];
54
52
  disabled: Props["disabled"];
55
53
  error: Props["error"];
56
54
  };
@@ -64,17 +62,10 @@ type DefaultProps = {
64
62
  * (because for Choice, that prop would be auto-populated by CheckboxGroup).
65
63
  */ export default class ChoiceInternal extends React.Component<Props> {
66
64
  static defaultProps: DefaultProps = {
67
- checked: false,
68
65
  disabled: false,
69
66
  error: false,
70
67
  };
71
68
 
72
- handleLabelClick: (event: React.SyntheticEvent) => void = (event) => {
73
- // Browsers automatically use the for attribute to select the input,
74
- // but we use ClickableBehavior to handle this.
75
- event.preventDefault();
76
- };
77
-
78
69
  handleClick: () => void = () => {
79
70
  const {checked, onChange, variant} = this.props;
80
71
  // Radio buttons cannot be unchecked
@@ -91,15 +82,13 @@ type DefaultProps = {
91
82
  return CheckboxCore;
92
83
  }
93
84
  }
94
- getLabel(): React.ReactNode {
95
- const {disabled, id, label} = this.props;
85
+ getLabel(id: string): React.ReactNode {
86
+ const {disabled, label} = this.props;
96
87
  return (
97
88
  <LabelMedium
98
89
  style={[styles.label, disabled && styles.disabledLabel]}
99
90
  >
100
- <label htmlFor={id} onClick={this.handleLabelClick}>
101
- {label}
102
- </label>
91
+ <label htmlFor={id}>{label}</label>
103
92
  </LabelMedium>
104
93
  );
105
94
  }
@@ -115,18 +104,30 @@ type DefaultProps = {
115
104
  const {
116
105
  label,
117
106
  description,
107
+ id,
118
108
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
119
109
  onChange,
120
110
  style,
121
111
  className,
112
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
122
113
  variant,
123
114
  ...coreProps
124
115
  } = this.props;
125
116
  const ChoiceCore = this.getChoiceCoreComponent();
126
- const ClickableBehavior = getClickableBehavior();
117
+
127
118
  return (
128
119
  <UniqueIDProvider mockOnFirstRender={true} scope="choice">
129
120
  {(ids) => {
121
+ // A choice element should always have a unique ID set
122
+ // so that the label can always refer to this element.
123
+ // This guarantees that clicking on the label will
124
+ // always click on the choice as well. If an ID is
125
+ // passed in as a prop, use that one. Otherwise,
126
+ // create a unique ID using the provider.
127
+ const uniqueId = id || ids.get("main");
128
+
129
+ // Create a unique ID for the description section to be
130
+ // used by this element's `aria-describedby`.
130
131
  const descriptionId = description
131
132
  ? ids.get("description")
132
133
  : undefined;
@@ -134,32 +135,22 @@ type DefaultProps = {
134
135
  return (
135
136
  // @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call.
136
137
  <View style={style} className={className}>
137
- <ClickableBehavior
138
- disabled={coreProps.disabled}
139
- onClick={this.handleClick}
140
- role={variant}
138
+ <View
139
+ style={styles.wrapper}
140
+ // We are resetting the tabIndex=0 from handlers
141
+ // because the ChoiceCore component will receive
142
+ // focus on basis of it being an input element.
143
+ tabIndex={-1}
141
144
  >
142
- {(state, childrenProps) => {
143
- return (
144
- <View
145
- style={styles.wrapper}
146
- {...childrenProps}
147
- // We are resetting the tabIndex=0 from handlers
148
- // because the ChoiceCore component will receive
149
- // focus on basis of it being an input element.
150
- tabIndex={-1}
151
- >
152
- <ChoiceCore
153
- {...coreProps}
154
- {...state}
155
- aria-describedby={descriptionId}
156
- />
157
- <Strut size={Spacing.xSmall_8} />
158
- {label && this.getLabel()}
159
- </View>
160
- );
161
- }}
162
- </ClickableBehavior>
145
+ <ChoiceCore
146
+ {...coreProps}
147
+ id={uniqueId}
148
+ aria-describedby={descriptionId}
149
+ onClick={this.handleClick}
150
+ />
151
+ <Strut size={Spacing.xSmall_8} />
152
+ {label && this.getLabel(uniqueId)}
153
+ </View>
163
154
  {description && this.getDescription(descriptionId)}
164
155
  </View>
165
156
  );
@@ -175,7 +166,6 @@ const styles = StyleSheet.create({
175
166
  outline: "none",
176
167
  },
177
168
  label: {
178
- userSelect: "none",
179
169
  // NOTE: The checkbox/radio button (height 16px) should be center
180
170
  // aligned with the first line of the label. However, LabelMedium has a
181
171
  // declared line height of 20px, so we need to adjust the top to get the