@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.
@@ -0,0 +1,4 @@
1
+ import { MutableRefObject } from 'react';
2
+
3
+ export declare const useMutableRefObject: <T>(value: T) => MutableRefObject<T>;
4
+ //# sourceMappingURL=utils.d.ts.map
@@ -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.2.0",
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/field-error-text": "5.2.0",
19
- "@purpurds/label": "5.2.0",
20
- "@purpurds/spinner": "5.2.0",
21
- "@purpurds/icon": "5.2.0",
22
- "@purpurds/tokens": "5.2.0",
23
- "@purpurds/field-helper-text": "5.2.0"
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": "18",
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
- <div>
45
- <TextField {...someProps}>Some content</TextField>
46
- </div>
46
+ <TextField
47
+ value={value}
48
+ onChange={setValue}
49
+ onClear={() => setValue("")}
50
+ clearButtonAllyLabel="Clear"
51
+ />
47
52
  );
48
53
  };
49
54
  ```
@@ -59,6 +59,10 @@
59
59
  }
60
60
  }
61
61
 
62
+ &--has-clear-button {
63
+ padding-right: var(--purpur-spacing-25);
64
+ }
65
+
62
66
  &--start-adornment {
63
67
  padding-left: var(--purpur-spacing-150);
64
68
 
@@ -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";
@@ -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
  });
@@ -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 { checkCircleFilled, Icon } from "@purpurds/icon";
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
- export type TextFieldProps = ComponentPropsWithoutRef<"input"> & {
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
- <Icon
162
+ <IconCheckCircleFilled
107
163
  key="valid-icon"
108
164
  data-testid={getTestId("valid-icon")}
109
- className={styles[`${rootClassName}__valid-icon`]}
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 = c([
118
- styles[`${rootClassName}__input-container`],
184
+ const inputContainerClassnames = cx([
185
+ `${rootClassName}__input-container`,
119
186
  {
120
- [styles[`${rootClassName}__input-container--start-adornment`]]: startAdornments.length,
121
- [styles[`${rootClassName}__input-container--end-adornment`]]: endAdornments.length,
122
- [styles[`${rootClassName}__input-container--disabled`]]: inputProps.disabled,
123
- [styles[`${rootClassName}__input-container--readonly`]]:
124
- inputProps.readOnly && !inputProps.disabled,
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={c(className, styles[rootClassName])}>
196
+ <div className={cx(className, rootClassName)}>
130
197
  {label && (
131
198
  <Label
132
199
  htmlFor={inputId}
133
- className={styles[`${rootClassName}__label`]}
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={styles[`${rootClassName}__field-row`]}>
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={styles[`${rootClassName}__adornment-container`]}
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={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={c([
158
- styles[`${rootClassName}__input`],
224
+ className={cx([
225
+ `${rootClassName}__input`,
159
226
  {
160
- [styles[`${rootClassName}__input--valid`]]: isValid,
161
- [styles[`${rootClassName}__input--error`]]: !!errorText,
227
+ [`${rootClassName}__input--valid`]: isValid,
228
+ [`${rootClassName}__input--error`]: !!errorText,
162
229
  },
163
230
  ])}
164
231
  />
165
- <div className={styles[`${rootClassName}__frame`]} />
232
+ <div className={cx(`${rootClassName}__frame`)} />
166
233
  {!!endAdornments.length && (
167
234
  <div
168
235
  data-testid={getTestId("end-adornments")}
169
- className={styles[`${rootClassName}__adornment-container`]}
236
+ className={cx(`${rootClassName}__adornment-container`)}
170
237
  >
171
238
  {endAdornments}
172
239
  </div>
package/src/utils.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { MutableRefObject, useRef } from "react";
2
+
3
+ export const useMutableRefObject = <T>(value: T): MutableRefObject<T> => {
4
+ return useRef<T>(value) as MutableRefObject<T>;
5
+ };