@purpurds/text-field 5.2.0 → 5.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/dist/LICENSE.txt +12 -5
- package/dist/styles.css +1 -1
- package/dist/text-field.cjs.js +16 -8
- package/dist/text-field.cjs.js.map +1 -1
- package/dist/text-field.d.ts +16 -43
- package/dist/text-field.d.ts.map +1 -1
- package/dist/text-field.es.js +677 -433
- package/dist/text-field.es.js.map +1 -1
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/package.json +9 -8
- package/readme.mdx +8 -3
- package/src/text-field.module.scss +4 -0
- package/src/text-field.stories.tsx +1 -0
- package/src/text-field.test.tsx +24 -2
- package/src/text-field.tsx +94 -27
- package/src/utils.ts +5 -0
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAU,MAAM,OAAO,CAAC;AAEjD,eAAO,MAAM,mBAAmB,aAAc,CAAC,KAAG,iBAAiB,CAAC,CAEnE,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@purpurds/text-field",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.0",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"main": "./dist/text-field.cjs.js",
|
|
6
6
|
"types": "./dist/text-field.d.ts",
|
|
@@ -15,12 +15,13 @@
|
|
|
15
15
|
"source": "src/text-field.tsx",
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"classnames": "~2.5.0",
|
|
18
|
-
"@purpurds/
|
|
19
|
-
"@purpurds/
|
|
20
|
-
"@purpurds/
|
|
21
|
-
"@purpurds/
|
|
22
|
-
"@purpurds/tokens": "5.
|
|
23
|
-
"@purpurds/field-helper-text": "5.
|
|
18
|
+
"@purpurds/button": "5.4.0",
|
|
19
|
+
"@purpurds/icon": "5.4.0",
|
|
20
|
+
"@purpurds/label": "5.4.0",
|
|
21
|
+
"@purpurds/field-error-text": "5.4.0",
|
|
22
|
+
"@purpurds/tokens": "5.4.0",
|
|
23
|
+
"@purpurds/field-helper-text": "5.4.0",
|
|
24
|
+
"@purpurds/spinner": "5.4.0"
|
|
24
25
|
},
|
|
25
26
|
"devDependencies": {
|
|
26
27
|
"@rushstack/eslint-patch": "~1.10.0",
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
"@testing-library/dom": "~9.3.3",
|
|
33
34
|
"@testing-library/jest-dom": "~6.4.0",
|
|
34
35
|
"@testing-library/react": "~14.3.0",
|
|
35
|
-
"@types/node": "
|
|
36
|
+
"@types/node": "20.12.12",
|
|
36
37
|
"@types/react-dom": "~18.3.0",
|
|
37
38
|
"@types/react": "~18.3.0",
|
|
38
39
|
"eslint-plugin-testing-library": "~6.2.0",
|
package/readme.mdx
CHANGED
|
@@ -40,10 +40,15 @@ In MyComponent.tsx
|
|
|
40
40
|
import { TextField } from "@purpurds/purpur";
|
|
41
41
|
|
|
42
42
|
export const MyComponent = () => {
|
|
43
|
+
const [value, setValue] = useState("");
|
|
44
|
+
|
|
43
45
|
return (
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
<TextField
|
|
47
|
+
value={value}
|
|
48
|
+
onChange={setValue}
|
|
49
|
+
onClear={() => setValue("")}
|
|
50
|
+
clearButtonAllyLabel="Clear"
|
|
51
|
+
/>
|
|
47
52
|
);
|
|
48
53
|
};
|
|
49
54
|
```
|
|
@@ -4,6 +4,7 @@ import type { Meta, StoryObj } from "@storybook/react";
|
|
|
4
4
|
|
|
5
5
|
import "@purpurds/label/styles";
|
|
6
6
|
import "@purpurds/field-helper-text/styles";
|
|
7
|
+
import "@purpurds/button/styles";
|
|
7
8
|
import "@purpurds/field-error-text/styles";
|
|
8
9
|
import "@purpurds/icon/styles";
|
|
9
10
|
import "@purpurds/spinner/styles";
|
package/src/text-field.test.tsx
CHANGED
|
@@ -108,8 +108,8 @@ describe("TextField", () => {
|
|
|
108
108
|
it("should render with adornment", () => {
|
|
109
109
|
render(
|
|
110
110
|
<TextField
|
|
111
|
-
startAdornment={<span data-testid="start-adornment" />}
|
|
112
|
-
endAdornment={<span data-testid="end-adornment" />}
|
|
111
|
+
startAdornment={<span key="start" data-testid="start-adornment" />}
|
|
112
|
+
endAdornment={<span key="end" data-testid="end-adornment" />}
|
|
113
113
|
id="test"
|
|
114
114
|
data-testid="test"
|
|
115
115
|
label="Test label"
|
|
@@ -215,4 +215,26 @@ describe("TextField", () => {
|
|
|
215
215
|
expect(input).toHaveValue("Changed");
|
|
216
216
|
expect(onChangeMock).toHaveBeenCalled();
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
it("should call onClear when clicking the clear button", async () => {
|
|
220
|
+
const onClearMock = vi.fn();
|
|
221
|
+
const onChangeMock = vi.fn();
|
|
222
|
+
render(
|
|
223
|
+
<TextField
|
|
224
|
+
id="test"
|
|
225
|
+
data-testid="test"
|
|
226
|
+
value="a value"
|
|
227
|
+
onClear={onClearMock}
|
|
228
|
+
onChange={onChangeMock}
|
|
229
|
+
clearButtonAllyLabel="Clear"
|
|
230
|
+
/>
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const button = screen.getByTestId("test-clear-button");
|
|
234
|
+
|
|
235
|
+
// Simulate click on the clear button
|
|
236
|
+
fireEvent.click(button);
|
|
237
|
+
|
|
238
|
+
expect(onClearMock).toHaveBeenCalledTimes(1);
|
|
239
|
+
});
|
|
218
240
|
});
|
package/src/text-field.tsx
CHANGED
|
@@ -6,19 +6,21 @@ import React, {
|
|
|
6
6
|
ReactNode,
|
|
7
7
|
useId,
|
|
8
8
|
} from "react";
|
|
9
|
+
import { Button } from "@purpurds/button";
|
|
9
10
|
import { FieldErrorText } from "@purpurds/field-error-text";
|
|
10
11
|
import { FieldHelperText } from "@purpurds/field-helper-text";
|
|
11
|
-
import {
|
|
12
|
+
import { IconCheckCircleFilled, IconClose } from "@purpurds/icon";
|
|
12
13
|
import { Label } from "@purpurds/label";
|
|
13
14
|
import { Spinner } from "@purpurds/spinner";
|
|
14
|
-
import c from "classnames";
|
|
15
|
+
import c from "classnames/bind";
|
|
15
16
|
|
|
16
17
|
import styles from "./text-field.module.scss";
|
|
18
|
+
import { useMutableRefObject } from "./utils";
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
id: string | undefined;
|
|
20
|
+
type TextFieldBaseProps = {
|
|
20
21
|
["data-testid"]?: string;
|
|
21
22
|
className?: string;
|
|
23
|
+
|
|
22
24
|
/**
|
|
23
25
|
* Use to display e.g. a button after the text field.
|
|
24
26
|
*
|
|
@@ -47,6 +49,7 @@ export type TextFieldProps = ComponentPropsWithoutRef<"input"> & {
|
|
|
47
49
|
* Use to render a spinner at the end inside of the text field.
|
|
48
50
|
*/
|
|
49
51
|
loading?: boolean;
|
|
52
|
+
|
|
50
53
|
/**
|
|
51
54
|
* Use to display e.g. an icon at the start inside of the text field.
|
|
52
55
|
*
|
|
@@ -67,18 +70,47 @@ export type TextFieldProps = ComponentPropsWithoutRef<"input"> & {
|
|
|
67
70
|
valid?: boolean;
|
|
68
71
|
};
|
|
69
72
|
|
|
73
|
+
type TextFieldClearProps =
|
|
74
|
+
| {
|
|
75
|
+
/**
|
|
76
|
+
* An accessible label for the clear button.
|
|
77
|
+
* */
|
|
78
|
+
clearButtonAllyLabel: string;
|
|
79
|
+
/**
|
|
80
|
+
* Event handler called when the clear button is clicked.
|
|
81
|
+
* */
|
|
82
|
+
onClear: () => void;
|
|
83
|
+
}
|
|
84
|
+
| {
|
|
85
|
+
/**
|
|
86
|
+
* An accessible label for the clear button.
|
|
87
|
+
* */
|
|
88
|
+
clearButtonAllyLabel?: never;
|
|
89
|
+
/**
|
|
90
|
+
* Event handler called when the clear button is clicked.
|
|
91
|
+
* */
|
|
92
|
+
onClear?: never;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type TextFieldProps = ComponentPropsWithoutRef<"input"> &
|
|
96
|
+
TextFieldBaseProps &
|
|
97
|
+
TextFieldClearProps;
|
|
98
|
+
|
|
99
|
+
const cx = c.bind(styles);
|
|
70
100
|
const rootClassName = "purpur-text-field";
|
|
71
101
|
|
|
72
102
|
const TextFieldComponent = (
|
|
73
103
|
{
|
|
74
104
|
["data-testid"]: dataTestId,
|
|
75
105
|
className,
|
|
106
|
+
clearButtonAllyLabel,
|
|
76
107
|
afterField,
|
|
77
108
|
endAdornment,
|
|
78
109
|
errorText,
|
|
79
110
|
helperText,
|
|
80
111
|
label,
|
|
81
112
|
loading = false,
|
|
113
|
+
onClear,
|
|
82
114
|
startAdornment,
|
|
83
115
|
valid = false,
|
|
84
116
|
...inputProps
|
|
@@ -90,8 +122,32 @@ const TextFieldComponent = (
|
|
|
90
122
|
const getTestId = (name: string) => (dataTestId ? `${dataTestId}-${name}` : undefined);
|
|
91
123
|
const isValid = valid && !errorText;
|
|
92
124
|
const helperTextId = helperText ? `${inputId}-helper-text` : undefined;
|
|
93
|
-
|
|
94
125
|
const startAdornments: ReactNode[] = [startAdornment].filter((adornment) => !!adornment);
|
|
126
|
+
const hasValue =
|
|
127
|
+
typeof inputProps.value === "number"
|
|
128
|
+
? inputProps.value !== undefined
|
|
129
|
+
: inputProps.value?.length;
|
|
130
|
+
const hasClearButton =
|
|
131
|
+
hasValue &&
|
|
132
|
+
!inputProps.disabled &&
|
|
133
|
+
!inputProps.readOnly &&
|
|
134
|
+
!loading &&
|
|
135
|
+
onClear &&
|
|
136
|
+
clearButtonAllyLabel;
|
|
137
|
+
|
|
138
|
+
const internalRef = useMutableRefObject<HTMLInputElement | null>(null);
|
|
139
|
+
const setRef = (node: HTMLInputElement | null) => {
|
|
140
|
+
internalRef.current = node;
|
|
141
|
+
if (typeof ref === "function") {
|
|
142
|
+
ref(node);
|
|
143
|
+
} else if (ref) {
|
|
144
|
+
ref.current = node;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const handleClear = () => {
|
|
148
|
+
onClear?.();
|
|
149
|
+
internalRef.current?.focus();
|
|
150
|
+
};
|
|
95
151
|
|
|
96
152
|
const endAdornments: ReactNode[] = [
|
|
97
153
|
loading && (
|
|
@@ -103,46 +159,57 @@ const TextFieldComponent = (
|
|
|
103
159
|
/>
|
|
104
160
|
),
|
|
105
161
|
isValid && (
|
|
106
|
-
<
|
|
162
|
+
<IconCheckCircleFilled
|
|
107
163
|
key="valid-icon"
|
|
108
164
|
data-testid={getTestId("valid-icon")}
|
|
109
|
-
className={
|
|
110
|
-
svg={checkCircleFilled}
|
|
111
|
-
size="md"
|
|
165
|
+
className={cx(`${rootClassName}__valid-icon`)}
|
|
112
166
|
/>
|
|
113
167
|
),
|
|
168
|
+
hasClearButton && (
|
|
169
|
+
<Button
|
|
170
|
+
key="clear-button"
|
|
171
|
+
variant="tertiary-purple"
|
|
172
|
+
onClick={handleClear}
|
|
173
|
+
iconOnly
|
|
174
|
+
aria-label={clearButtonAllyLabel ?? ""}
|
|
175
|
+
data-testid={getTestId("clear-button")}
|
|
176
|
+
tabIndex={-1}
|
|
177
|
+
>
|
|
178
|
+
<IconClose size="xs" />
|
|
179
|
+
</Button>
|
|
180
|
+
),
|
|
114
181
|
endAdornment,
|
|
115
182
|
].filter((adornment) => !!adornment);
|
|
116
183
|
|
|
117
|
-
const inputContainerClassnames =
|
|
118
|
-
|
|
184
|
+
const inputContainerClassnames = cx([
|
|
185
|
+
`${rootClassName}__input-container`,
|
|
119
186
|
{
|
|
120
|
-
[
|
|
121
|
-
[
|
|
122
|
-
[
|
|
123
|
-
[
|
|
124
|
-
|
|
187
|
+
[`${rootClassName}__input-container--start-adornment`]: startAdornments.length,
|
|
188
|
+
[`${rootClassName}__input-container--end-adornment`]: endAdornments.length,
|
|
189
|
+
[`${rootClassName}__input-container--disabled`]: inputProps.disabled,
|
|
190
|
+
[`${rootClassName}__input-container--has-clear-button`]: hasClearButton,
|
|
191
|
+
[`${rootClassName}__input-container--readonly`]: inputProps.readOnly && !inputProps.disabled,
|
|
125
192
|
},
|
|
126
193
|
]);
|
|
127
194
|
|
|
128
195
|
return (
|
|
129
|
-
<div className={
|
|
196
|
+
<div className={cx(className, rootClassName)}>
|
|
130
197
|
{label && (
|
|
131
198
|
<Label
|
|
132
199
|
htmlFor={inputId}
|
|
133
|
-
className={
|
|
200
|
+
className={cx(`${rootClassName}__label`)}
|
|
134
201
|
data-testid={getTestId("label")}
|
|
135
202
|
disabled={inputProps.disabled}
|
|
136
203
|
>
|
|
137
204
|
{`${inputProps.required ? "* " : ""}${label}`}
|
|
138
205
|
</Label>
|
|
139
206
|
)}
|
|
140
|
-
<div className={
|
|
207
|
+
<div className={cx(`${rootClassName}__field-row`)}>
|
|
141
208
|
<div className={inputContainerClassnames}>
|
|
142
209
|
{!!startAdornments.length && (
|
|
143
210
|
<div
|
|
144
211
|
data-testid={getTestId("start-adornments")}
|
|
145
|
-
className={
|
|
212
|
+
className={cx(`${rootClassName}__adornment-container`)}
|
|
146
213
|
>
|
|
147
214
|
{startAdornments}
|
|
148
215
|
</div>
|
|
@@ -150,23 +217,23 @@ const TextFieldComponent = (
|
|
|
150
217
|
<input
|
|
151
218
|
{...inputProps}
|
|
152
219
|
id={inputId}
|
|
153
|
-
ref={
|
|
220
|
+
ref={setRef}
|
|
154
221
|
data-testid={getTestId("input")}
|
|
155
222
|
aria-describedby={inputProps["aria-describedby"] || helperTextId}
|
|
156
223
|
aria-invalid={inputProps["aria-invalid"] || !!errorText}
|
|
157
|
-
className={
|
|
158
|
-
|
|
224
|
+
className={cx([
|
|
225
|
+
`${rootClassName}__input`,
|
|
159
226
|
{
|
|
160
|
-
[
|
|
161
|
-
[
|
|
227
|
+
[`${rootClassName}__input--valid`]: isValid,
|
|
228
|
+
[`${rootClassName}__input--error`]: !!errorText,
|
|
162
229
|
},
|
|
163
230
|
])}
|
|
164
231
|
/>
|
|
165
|
-
<div className={
|
|
232
|
+
<div className={cx(`${rootClassName}__frame`)} />
|
|
166
233
|
{!!endAdornments.length && (
|
|
167
234
|
<div
|
|
168
235
|
data-testid={getTestId("end-adornments")}
|
|
169
|
-
className={
|
|
236
|
+
className={cx(`${rootClassName}__adornment-container`)}
|
|
170
237
|
>
|
|
171
238
|
{endAdornments}
|
|
172
239
|
</div>
|