@khanacademy/wonder-blocks-form 4.7.5 → 4.8.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.
@@ -0,0 +1,410 @@
1
+ import * as React from "react";
2
+ import {CSSProperties, Falsy, StyleSheet} from "aphrodite";
3
+
4
+ import {
5
+ AriaProps,
6
+ StyleType,
7
+ useOnMountEffect,
8
+ useUniqueIdWithMock,
9
+ addStyle,
10
+ View,
11
+ } from "@khanacademy/wonder-blocks-core";
12
+ import {border, color, mix, spacing} from "@khanacademy/wonder-blocks-tokens";
13
+ import {styles as typographyStyles} from "@khanacademy/wonder-blocks-typography";
14
+
15
+ type TextAreaProps = AriaProps & {
16
+ /**
17
+ * The text area value.
18
+ */
19
+ value: string;
20
+ /**
21
+ * Called when the value has changed.
22
+ */
23
+ onChange: (newValue: string) => unknown;
24
+ /**
25
+ * An optional unique identifier for the TextArea.
26
+ * If no id is specified, a unique id will be auto-generated.
27
+ */
28
+ id?: string;
29
+ /**
30
+ * Optional test ID for e2e testing.
31
+ */
32
+ testId?: string;
33
+ /**
34
+ * Custom styles for the text area.
35
+ */
36
+ style?: StyleType;
37
+ /**
38
+ * Provide hints or examples of what to enter.
39
+ */
40
+ placeholder?: string;
41
+ /**
42
+ * Whether the text area should be disabled.
43
+ */
44
+ disabled?: boolean;
45
+ /**
46
+ * Specifies if the text area is read-only.
47
+ */
48
+ readOnly?: boolean;
49
+ /**
50
+ * Specifies if the text area allows autocomplete.
51
+ */
52
+ autoComplete?: "on" | "off";
53
+ /**
54
+ * The name for the text area control. This is submitted along with
55
+ * the form data.
56
+ */
57
+ name?: string;
58
+ /**
59
+ * CSS classes for the textarea element. It is recommended that the style prop is used instead where possible
60
+ */
61
+ className?: string;
62
+ /**
63
+ * Whether this textarea should autofocus on page load.
64
+ */
65
+ autoFocus?: boolean;
66
+ /**
67
+ * The number of visible lines of text for the textarea.
68
+ * By default, 2 rows are shown.
69
+ * `rows` is ignored if a height is applied to the textarea using CSS.
70
+ * The number of rows can change if the resize control is used by the user.
71
+ */
72
+ rows?: number;
73
+ /**
74
+ * Determines if the textarea should be checked for spelling by the browser/OS.
75
+ * By default, it is enabled. It will be checked for spelling when you try
76
+ * to edit it (ie. once the textarea is focused). For more details, see the
77
+ * [spellcheck attribute MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea#spellcheck).
78
+ * **Note**: Consider disabling `spellCheck` for
79
+ * sensitive information (see [Security and Privacy concerns](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/spellcheck#security_and_privacy_concerns) for more details)
80
+ */
81
+ spellCheck?: boolean;
82
+ /**
83
+ * How the control should wrap the value for form submission. If not provided,
84
+ * `soft` is the default behaviour. For more details, see the
85
+ * [wrap attribute MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea#wrap)
86
+ */
87
+ wrap?: "hard" | "soft" | "off";
88
+ /**
89
+ * The minimum number of characters allowed in the textarea.
90
+ */
91
+ minLength?: number;
92
+ /**
93
+ * The maximum number of characters allowed in the textarea.
94
+ */
95
+ maxLength?: number;
96
+ /**
97
+ * Called when the textarea is clicked.
98
+ * @param event The event from the click
99
+ */
100
+ onClick?: React.MouseEventHandler<HTMLTextAreaElement>;
101
+ /**
102
+ * Called when a key is pressed.
103
+ * @param event The keyboard event
104
+ */
105
+ onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>;
106
+ /**
107
+ * Called when a key is released.
108
+ * @param event The keyboard event
109
+ */
110
+ onKeyUp?: React.KeyboardEventHandler<HTMLTextAreaElement>;
111
+ /**
112
+ * Called when the element has been focused.
113
+ * @param event The focus event
114
+ */
115
+ onFocus?: React.FocusEventHandler<HTMLTextAreaElement>;
116
+ /**
117
+ * Called when the element has been focused.
118
+ * @param event The blur event
119
+ */
120
+ onBlur?: React.FocusEventHandler<HTMLTextAreaElement>;
121
+ /**
122
+ * Provide a validation for the textarea value.
123
+ * Return a string error message or null | void for a valid input.
124
+ */
125
+ validate?: (value: string) => string | null | void;
126
+ /**
127
+ * Called right after the textarea is validated.
128
+ */
129
+ onValidate?: (errorMessage?: string | null | undefined) => unknown;
130
+ /**
131
+ * Whether this textarea is required to continue, or the error message to
132
+ * render if this textarea is left blank.
133
+ *
134
+ * This can be a boolean or a string.
135
+ *
136
+ * String:
137
+ * Please pass in a translated string to use as the error message that will
138
+ * render if the user leaves this textarea blank. If this textarea is required,
139
+ * and a string is not passed in, a default untranslated string will render
140
+ * upon error.
141
+ * Note: The string will not be used if a `validate` prop is passed in.
142
+ *
143
+ * Example message: i18n._("A password is required to log in.")
144
+ *
145
+ * Boolean:
146
+ * True/false indicating whether this textarea is required. Please do not pass
147
+ * in `true` if possible - pass in the error string instead.
148
+ * If `true` is passed, and a `validate` prop is not passed, that means
149
+ * there is no corresponding message and the default untranlsated message
150
+ * will be used.
151
+ */
152
+ required?: boolean | string;
153
+ /**
154
+ * Specifies the resizing behaviour for the textarea. Defaults to both
155
+ * behaviour. For more details, see the [CSS resize property values MDN docs](https://developer.mozilla.org/en-US/docs/Web/CSS/resize#values)
156
+ */
157
+ resizeType?: "horizontal" | "vertical" | "both" | "none";
158
+ /**
159
+ * Change the default focus ring color to fit a dark background.
160
+ */
161
+ light?: boolean;
162
+ };
163
+
164
+ const defaultErrorMessage = "This field is required.";
165
+
166
+ const StyledTextArea = addStyle("textarea");
167
+
168
+ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
169
+ function TextArea(
170
+ props: TextAreaProps,
171
+ ref: React.ForwardedRef<HTMLTextAreaElement>,
172
+ ) {
173
+ const {
174
+ onChange,
175
+ value,
176
+ placeholder,
177
+ disabled,
178
+ id,
179
+ testId,
180
+ style,
181
+ readOnly,
182
+ autoComplete,
183
+ name,
184
+ className,
185
+ autoFocus,
186
+ rows,
187
+ spellCheck,
188
+ wrap,
189
+ minLength,
190
+ maxLength,
191
+ onClick,
192
+ onKeyDown,
193
+ onKeyUp,
194
+ onFocus,
195
+ onBlur,
196
+ validate,
197
+ onValidate,
198
+ required,
199
+ resizeType,
200
+ light,
201
+ // Should only include aria related props
202
+ ...otherProps
203
+ } = props;
204
+
205
+ const [error, setError] = React.useState<string | null>(null);
206
+
207
+ const ids = useUniqueIdWithMock("text-area");
208
+ const uniqueId = id ?? ids.get("id");
209
+
210
+ const handleChange = (
211
+ event: React.ChangeEvent<HTMLTextAreaElement>,
212
+ ) => {
213
+ const newValue = event.target.value;
214
+ onChange(newValue);
215
+ handleValidation(newValue);
216
+ };
217
+
218
+ const handleValidation = (newValue: string) => {
219
+ if (validate) {
220
+ const error = validate(newValue) || null;
221
+ setError(error);
222
+ if (onValidate) {
223
+ onValidate(error);
224
+ }
225
+ } else if (required) {
226
+ const requiredString =
227
+ typeof required === "string"
228
+ ? required
229
+ : defaultErrorMessage;
230
+ const error = newValue ? null : requiredString;
231
+ setError(error);
232
+ if (onValidate) {
233
+ onValidate(error);
234
+ }
235
+ }
236
+ };
237
+
238
+ useOnMountEffect(() => {
239
+ // Only validate on mount if the value is not empty. This is so that fields
240
+ // don't render an error when they are initially empty
241
+ if (value !== "") {
242
+ handleValidation(value);
243
+ }
244
+ });
245
+
246
+ const getStyles = (): (CSSProperties | Falsy)[] => {
247
+ // Base styles are the styles that apply regardless of light mode
248
+ const baseStyles = [
249
+ styles.textarea,
250
+ typographyStyles.LabelMedium,
251
+ resizeType && resizeStyles[resizeType],
252
+ ];
253
+ const defaultStyles = [
254
+ styles.default,
255
+ !disabled && styles.defaultFocus,
256
+ disabled && styles.disabled,
257
+ !!error && styles.error,
258
+ ];
259
+ const lightStyles = [
260
+ styles.light,
261
+ !disabled && styles.lightFocus,
262
+ disabled && styles.lightDisabled,
263
+ !!error && styles.lightError,
264
+ ];
265
+ return [...baseStyles, ...(light ? lightStyles : defaultStyles)];
266
+ };
267
+ return (
268
+ <View style={{width: "100%"}}>
269
+ <StyledTextArea
270
+ id={uniqueId}
271
+ data-testid={testId}
272
+ ref={ref}
273
+ className={className}
274
+ style={[...getStyles(), style]}
275
+ value={value}
276
+ onChange={handleChange}
277
+ placeholder={placeholder}
278
+ aria-disabled={disabled}
279
+ readOnly={readOnly || disabled} // Set readOnly also if it is disabled, otherwise users can type in the field
280
+ autoComplete={autoComplete}
281
+ name={name}
282
+ autoFocus={autoFocus}
283
+ rows={rows}
284
+ spellCheck={spellCheck}
285
+ wrap={wrap}
286
+ minLength={minLength}
287
+ maxLength={maxLength}
288
+ onClick={disabled ? undefined : onClick}
289
+ onKeyDown={disabled ? undefined : onKeyDown}
290
+ onKeyUp={disabled ? undefined : onKeyUp}
291
+ onFocus={onFocus} // TextArea can be focused on if it is disabled
292
+ onBlur={onBlur} // TextArea can be blurred if it is disabled
293
+ required={!!required}
294
+ {...otherProps}
295
+ aria-invalid={!!error}
296
+ />
297
+ </View>
298
+ );
299
+ },
300
+ );
301
+
302
+ const styles = StyleSheet.create({
303
+ textarea: {
304
+ borderRadius: border.radius.medium_4,
305
+ boxSizing: "border-box",
306
+ padding: `10px ${spacing.medium_16}px`,
307
+ minHeight: "1em",
308
+ },
309
+ default: {
310
+ background: color.white,
311
+ border: `1px solid ${color.offBlack50}`,
312
+ color: color.offBlack,
313
+ "::placeholder": {
314
+ color: color.offBlack64,
315
+ },
316
+ },
317
+ defaultFocus: {
318
+ ":focus-visible": {
319
+ borderColor: color.blue,
320
+ outline: `1px solid ${color.blue}`,
321
+ outlineOffset: 0, // Explicitly set outline offset to 0 because Safari sets a default offset
322
+ },
323
+ },
324
+ disabled: {
325
+ background: color.offWhite,
326
+ border: `1px solid ${color.offBlack16}`,
327
+ color: color.offBlack64,
328
+ "::placeholder": {
329
+ color: color.offBlack64,
330
+ },
331
+ cursor: "not-allowed",
332
+ ":focus-visible": {
333
+ outline: "none",
334
+ boxShadow: `0 0 0 1px ${color.white}, 0 0 0 3px ${color.offBlack32}`,
335
+ },
336
+ },
337
+ error: {
338
+ background: color.fadedRed8,
339
+ border: `1px solid ${color.red}`,
340
+ color: color.offBlack,
341
+ "::placeholder": {
342
+ color: color.offBlack64,
343
+ },
344
+ ":focus-visible": {
345
+ outlineColor: color.red,
346
+ borderColor: color.red,
347
+ },
348
+ },
349
+ light: {
350
+ background: color.white,
351
+ border: `1px solid ${color.offBlack16}`,
352
+ color: color.offBlack,
353
+ "::placeholder": {
354
+ color: color.offBlack64,
355
+ },
356
+ },
357
+ lightFocus: {
358
+ ":focus-visible": {
359
+ outline: `1px solid ${color.blue}`,
360
+ outlineOffset: 0, // Explicitly set outline offset to 0 because Safari sets a default offset
361
+ borderColor: color.blue,
362
+ boxShadow: `0px 0px 0px 2px ${color.blue}, 0px 0px 0px 3px ${color.white}`,
363
+ },
364
+ },
365
+ lightDisabled: {
366
+ backgroundColor: "transparent",
367
+ border: `1px solid ${color.white32}`,
368
+ color: color.white64,
369
+ "::placeholder": {
370
+ color: color.white64,
371
+ },
372
+ cursor: "not-allowed",
373
+ ":focus-visible": {
374
+ borderColor: mix(color.white32, color.blue),
375
+ outline: "none",
376
+ boxShadow: `0 0 0 1px ${color.offBlack32}, 0 0 0 3px ${color.fadedBlue}`,
377
+ },
378
+ },
379
+ lightError: {
380
+ background: color.fadedRed8,
381
+ border: `1px solid ${color.red}`,
382
+ boxShadow: `0px 0px 0px 1px ${color.red}, 0px 0px 0px 2px ${color.white}`,
383
+ color: color.offBlack,
384
+ "::placeholder": {
385
+ color: color.offBlack64,
386
+ },
387
+ ":focus-visible": {
388
+ outlineColor: color.red,
389
+ borderColor: color.red,
390
+ boxShadow: `0px 0px 0px 2px ${color.red}, 0px 0px 0px 3px ${color.white}`,
391
+ },
392
+ },
393
+ });
394
+
395
+ const resizeStyles = StyleSheet.create({
396
+ both: {
397
+ resize: "both",
398
+ },
399
+ none: {
400
+ resize: "none",
401
+ },
402
+ horizontal: {
403
+ resize: "horizontal",
404
+ },
405
+ vertical: {
406
+ resize: "vertical",
407
+ },
408
+ });
409
+
410
+ export default TextArea;
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import CheckboxGroup from "./components/checkbox-group";
4
4
  import RadioGroup from "./components/radio-group";
5
5
  import TextField from "./components/text-field";
6
6
  import LabeledTextField from "./components/labeled-text-field";
7
+ import TextArea from "./components/text-area";
7
8
 
8
9
  export {
9
10
  Checkbox,
@@ -12,4 +13,5 @@ export {
12
13
  RadioGroup,
13
14
  TextField,
14
15
  LabeledTextField,
16
+ TextArea,
15
17
  };