@coopdigital/react 0.33.0 → 0.34.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.
@@ -3,7 +3,10 @@ import { FormFieldError, StandardSizes } from "../../../src/types";
3
3
  export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "type"> {
4
4
  /** **(Optional)** Specify additional CSS classes to be applied to the component. */
5
5
  className?: string;
6
- /** **(Optional)** Specify the Input error. */
6
+ /** **(Optional)** Specify the Input error state.
7
+ *
8
+ * This is an instance of `FormFieldError`. You can provide either an object with a `message` key, or a boolean value if you need to render the message independently.
9
+ */
7
10
  error?: FormFieldError;
8
11
  /** **(Optional)** Specify the Input hint.
9
12
  *
@@ -0,0 +1,47 @@
1
+ import type { JSX, TextareaHTMLAttributes } from "react";
2
+ import { FormFieldError, StandardSizes } from "../../types";
3
+ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
4
+ /** **(Optional)** Specify additional CSS classes to be applied to the component. */
5
+ className?: string;
6
+ /** Specify the number of columns (characters per row) in the Textarea. Defaults to `30`. */
7
+ cols?: number;
8
+ /** **(Optional)** Specify whether the Textarea should allow more characters than its `maxLength` value.
9
+ *
10
+ * Defaults to `false`, meaning users can enter more characters than the maximum but will be warned if they go over the limit. When set to `true`, users will be blocked from typing once they hit the character limit. This can be an accessiblity anti-pattern, so only use this option when absolutely necessary.
11
+ *
12
+ * Remember it is still your responsibility to handle validation on submission, this is simply a hint for the user.
13
+ */
14
+ cutoff?: boolean;
15
+ /** **(Optional)** Specify the Textarea error state.
16
+ *
17
+ * This is an instance of `FormFieldError`. You can provide either an object with a `message` key, or a boolean value if you need to render the message independently.
18
+ */
19
+ error?: FormFieldError;
20
+ /** **(Optional)** Specify the Textarea hint.
21
+ *
22
+ * This text is rendered under the label to provide further guidance for users.
23
+ */
24
+ hint?: string;
25
+ /** **(Optional)** Specify the Textarea id. Will be auto-generated if not set. */
26
+ id?: string;
27
+ /** **(Optional)** Specify the Textarea label.
28
+ *
29
+ * This property is optional in case you need to render your own label, but all form elements *must* provide a label. */
30
+ label?: string;
31
+ /** **(Optional)** Specify whether the label should be visible to humans or screen readers. */
32
+ labelVisible?: boolean;
33
+ /** **(Optional)** Specify the Textarea maxLength. This is the maximum number of characters users can enter. */
34
+ maxLength?: number;
35
+ /** Specify the Textarea name. */
36
+ name: string;
37
+ /** **(Optional)** Specify the Textarea placeholder text. Do not use in place of a form label. */
38
+ placeholder?: string;
39
+ /** Specify the number of rows (lines of text) in the Textarea. Defaults to `4`. */
40
+ rows?: number;
41
+ /** **(Optional)** Specify whether to show the remaining character count underneath the Textarea. */
42
+ showRemaining?: boolean;
43
+ /** **(Optional)** Specify the Textarea size. */
44
+ size?: StandardSizes;
45
+ }
46
+ export declare const Textarea: ({ "aria-placeholder": ariaPlaceholder, className, cols, cutoff, error, hint, id, label, labelVisible, maxLength, name, onChange: userOnChange, placeholder, rows, showRemaining, size, ...props }: TextareaProps) => JSX.Element;
47
+ export default Textarea;
@@ -0,0 +1,41 @@
1
+ import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
2
+ import { clsx } from '../../node_modules/clsx/dist/clsx.js';
3
+ import { useId, useState } from 'react';
4
+ import { useDebounce } from '../../hooks/useDebounce.js';
5
+ import { FieldError } from '../FieldError/FieldError.js';
6
+ import { FieldHint } from '../FieldHint/FieldHint.js';
7
+ import { FieldLabel } from '../FieldLabel/FieldLabel.js';
8
+
9
+ const DEBOUNCE_DELAY = 750;
10
+ const charCountMessage = (remaining) => {
11
+ return `You have ${Math.abs(remaining)} ${Math.abs(remaining) === 1 ? "character" : "characters"} ${remaining < 0 ? "too many" : "remaining"}`;
12
+ };
13
+ const Textarea = ({ "aria-placeholder": ariaPlaceholder, className, cols = 30, cutoff = false, error = false, hint, id, label, labelVisible = true, maxLength, name, onChange: userOnChange = undefined, placeholder, rows = 4, showRemaining = false, size = "md", ...props }) => {
14
+ var _a;
15
+ const internalId = useId();
16
+ id = id !== null && id !== void 0 ? id : internalId;
17
+ const componentProps = {
18
+ "aria-placeholder": (_a = placeholder !== null && placeholder !== void 0 ? placeholder : ariaPlaceholder) !== null && _a !== void 0 ? _a : undefined,
19
+ className: clsx("coop-textarea", className),
20
+ cols,
21
+ "data-error": error ? "" : undefined,
22
+ "data-size": size.length && size !== "md" ? size : undefined,
23
+ id,
24
+ maxLength: cutoff ? maxLength : undefined,
25
+ name,
26
+ placeholder,
27
+ rows,
28
+ ...props,
29
+ };
30
+ const [remaining, setRemaining] = useState(maxLength);
31
+ const debouncedRemaining = useDebounce(remaining, DEBOUNCE_DELAY);
32
+ const handleChange = (e) => {
33
+ maxLength && e.target && setRemaining(maxLength - e.target.value.length);
34
+ };
35
+ return (jsxs(Fragment, { children: [label && (jsx(FieldLabel, { htmlFor: id, isVisible: labelVisible, children: label })), hint && jsx(FieldHint, { children: hint }), typeof error === "object" && (error === null || error === void 0 ? void 0 : error.message) && jsx(FieldError, { children: error.message }), jsx("div", { className: "coop-field-control", children: jsx("textarea", { ...componentProps, onChange: (e) => {
36
+ userOnChange === null || userOnChange === void 0 ? void 0 : userOnChange(e);
37
+ handleChange(e);
38
+ } }) }), showRemaining && maxLength && remaining != null && debouncedRemaining != null && (jsxs(Fragment, { children: [jsx("small", { "aria-hidden": "true", className: "coop-textarea-counter", ...(remaining < 0 && { "data-error": "" }), children: charCountMessage(remaining) }), jsx("span", { "aria-live": "polite", className: "sr-only", children: charCountMessage(debouncedRemaining) })] }))] }));
39
+ };
40
+
41
+ export { Textarea, Textarea as default };
@@ -0,0 +1,4 @@
1
+ import Textarea from "./Textarea";
2
+ export default Textarea;
3
+ export { Textarea };
4
+ export * from "./Textarea";
@@ -0,0 +1 @@
1
+ export declare function useDebounce<T>(value: T, delay: number): T;
@@ -0,0 +1,16 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ function useDebounce(value, delay) {
4
+ const [debouncedValue, setDebouncedValue] = useState(value);
5
+ useEffect(() => {
6
+ const handler = setTimeout(() => {
7
+ setDebouncedValue(value);
8
+ }, delay);
9
+ return () => {
10
+ clearTimeout(handler);
11
+ };
12
+ }, [value, delay]);
13
+ return debouncedValue;
14
+ }
15
+
16
+ export { useDebounce };
package/dist/index.d.ts CHANGED
@@ -17,3 +17,4 @@ export * from "./components/Signpost";
17
17
  export * from "./components/SkipNav";
18
18
  export * from "./components/Squircle";
19
19
  export * from "./components/Tag";
20
+ export * from "./components/Textarea";
package/dist/index.js CHANGED
@@ -17,3 +17,4 @@ export { Signpost } from './components/Signpost/Signpost.js';
17
17
  export { SkipNav } from './components/SkipNav/SkipNav.js';
18
18
  export { Squircle } from './components/Squircle/Squircle.js';
19
19
  export { Tag } from './components/Tag/Tag.js';
20
+ export { Textarea } from './components/Textarea/Textarea.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@coopdigital/react",
3
3
  "type": "module",
4
- "version": "0.33.0",
4
+ "version": "0.34.0",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -80,7 +80,7 @@
80
80
  "storybook": "$storybook"
81
81
  },
82
82
  "dependencies": {
83
- "@coopdigital/styles": "^0.29.0"
83
+ "@coopdigital/styles": "^0.30.0"
84
84
  },
85
- "gitHead": "b7f3df204e1f4e38b3c978af0506243b30365b4a"
85
+ "gitHead": "858102db210474fc31e01a2f4dc97551af2621d3"
86
86
  }
@@ -11,7 +11,10 @@ import { FieldLabel } from "../FieldLabel"
11
11
  export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "type"> {
12
12
  /** **(Optional)** Specify additional CSS classes to be applied to the component. */
13
13
  className?: string
14
- /** **(Optional)** Specify the Input error. */
14
+ /** **(Optional)** Specify the Input error state.
15
+ *
16
+ * This is an instance of `FormFieldError`. You can provide either an object with a `message` key, or a boolean value if you need to render the message independently.
17
+ */
15
18
  error?: FormFieldError
16
19
  /** **(Optional)** Specify the Input hint.
17
20
  *
@@ -0,0 +1,145 @@
1
+ import type { ChangeEvent, JSX, TextareaHTMLAttributes } from "react"
2
+
3
+ import clsx from "clsx"
4
+ import { useId, useState } from "react"
5
+
6
+ import { useDebounce } from "../../hooks/useDebounce"
7
+ import { FormFieldError, StandardSizes } from "../../types"
8
+ import { FieldError } from "../FieldError"
9
+ import { FieldHint } from "../FieldHint"
10
+ import { FieldLabel } from "../FieldLabel"
11
+
12
+ const DEBOUNCE_DELAY = 750
13
+
14
+ const charCountMessage = (remaining: number) => {
15
+ return `You have ${Math.abs(remaining)} ${Math.abs(remaining) === 1 ? "character" : "characters"} ${remaining < 0 ? "too many" : "remaining"}`
16
+ }
17
+
18
+ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
19
+ /** **(Optional)** Specify additional CSS classes to be applied to the component. */
20
+ className?: string
21
+ /** Specify the number of columns (characters per row) in the Textarea. Defaults to `30`. */
22
+ cols?: number
23
+ /** **(Optional)** Specify whether the Textarea should allow more characters than its `maxLength` value.
24
+ *
25
+ * Defaults to `false`, meaning users can enter more characters than the maximum but will be warned if they go over the limit. When set to `true`, users will be blocked from typing once they hit the character limit. This can be an accessiblity anti-pattern, so only use this option when absolutely necessary.
26
+ *
27
+ * Remember it is still your responsibility to handle validation on submission, this is simply a hint for the user.
28
+ */
29
+ cutoff?: boolean
30
+ /** **(Optional)** Specify the Textarea error state.
31
+ *
32
+ * This is an instance of `FormFieldError`. You can provide either an object with a `message` key, or a boolean value if you need to render the message independently.
33
+ */
34
+ error?: FormFieldError
35
+ /** **(Optional)** Specify the Textarea hint.
36
+ *
37
+ * This text is rendered under the label to provide further guidance for users.
38
+ */
39
+ hint?: string
40
+ /** **(Optional)** Specify the Textarea id. Will be auto-generated if not set. */
41
+ id?: string
42
+ /** **(Optional)** Specify the Textarea label.
43
+ *
44
+ * This property is optional in case you need to render your own label, but all form elements *must* provide a label. */
45
+ label?: string
46
+ /** **(Optional)** Specify whether the label should be visible to humans or screen readers. */
47
+ labelVisible?: boolean
48
+ /** **(Optional)** Specify the Textarea maxLength. This is the maximum number of characters users can enter. */
49
+ maxLength?: number
50
+ /** Specify the Textarea name. */
51
+ name: string
52
+ /** **(Optional)** Specify the Textarea placeholder text. Do not use in place of a form label. */
53
+ placeholder?: string
54
+ /** Specify the number of rows (lines of text) in the Textarea. Defaults to `4`. */
55
+ rows?: number
56
+ /** **(Optional)** Specify whether to show the remaining character count underneath the Textarea. */
57
+ showRemaining?: boolean
58
+ /** **(Optional)** Specify the Textarea size. */
59
+ size?: StandardSizes
60
+ }
61
+
62
+ export const Textarea = ({
63
+ "aria-placeholder": ariaPlaceholder,
64
+ className,
65
+ cols = 30,
66
+ cutoff = false,
67
+ error = false,
68
+ hint,
69
+ id,
70
+ label,
71
+ labelVisible = true,
72
+ maxLength,
73
+ name,
74
+ onChange: userOnChange = undefined,
75
+ placeholder,
76
+ rows = 4,
77
+ showRemaining = false,
78
+ size = "md",
79
+ ...props
80
+ }: TextareaProps): JSX.Element => {
81
+ const internalId = useId()
82
+
83
+ id = id ?? internalId
84
+
85
+ const componentProps = {
86
+ "aria-placeholder": placeholder ?? ariaPlaceholder ?? undefined,
87
+ className: clsx("coop-textarea", className),
88
+ cols,
89
+ "data-error": error ? "" : undefined,
90
+ "data-size": size.length && size !== "md" ? size : undefined,
91
+ id,
92
+ maxLength: cutoff ? maxLength : undefined,
93
+ name,
94
+ placeholder,
95
+ rows,
96
+ ...props,
97
+ }
98
+
99
+ const [remaining, setRemaining] = useState(maxLength)
100
+ const debouncedRemaining = useDebounce(remaining, DEBOUNCE_DELAY)
101
+
102
+ const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
103
+ maxLength && e.target && setRemaining(maxLength - e.target.value.length)
104
+ }
105
+
106
+ return (
107
+ <>
108
+ {label && (
109
+ <FieldLabel htmlFor={id} isVisible={labelVisible}>
110
+ {label}
111
+ </FieldLabel>
112
+ )}
113
+
114
+ {hint && <FieldHint>{hint}</FieldHint>}
115
+
116
+ {typeof error === "object" && error?.message && <FieldError>{error.message}</FieldError>}
117
+ <div className="coop-field-control">
118
+ <textarea
119
+ {...componentProps}
120
+ onChange={(e) => {
121
+ userOnChange?.(e)
122
+ handleChange(e)
123
+ }}
124
+ ></textarea>
125
+ </div>
126
+
127
+ {showRemaining && maxLength && remaining != null && debouncedRemaining != null && (
128
+ <>
129
+ <small
130
+ aria-hidden="true"
131
+ className="coop-textarea-counter"
132
+ {...(remaining < 0 && { "data-error": "" })}
133
+ >
134
+ {charCountMessage(remaining)}
135
+ </small>
136
+ <span aria-live="polite" className="sr-only">
137
+ {charCountMessage(debouncedRemaining)}
138
+ </span>
139
+ </>
140
+ )}
141
+ </>
142
+ )
143
+ }
144
+
145
+ export default Textarea
@@ -0,0 +1,5 @@
1
+ import Textarea from "./Textarea"
2
+
3
+ export default Textarea
4
+ export { Textarea }
5
+ export * from "./Textarea"
@@ -0,0 +1,16 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ export function useDebounce<T>(value: T, delay: number) {
4
+ const [debouncedValue, setDebouncedValue] = useState(value)
5
+ useEffect(() => {
6
+ const handler = setTimeout(() => {
7
+ setDebouncedValue(value)
8
+ }, delay)
9
+
10
+ return () => {
11
+ clearTimeout(handler)
12
+ }
13
+ }, [value, delay])
14
+
15
+ return debouncedValue
16
+ }
package/src/index.ts CHANGED
@@ -17,3 +17,4 @@ export * from "./components/Signpost"
17
17
  export * from "./components/SkipNav"
18
18
  export * from "./components/Squircle"
19
19
  export * from "./components/Tag"
20
+ export * from "./components/Textarea"