@purpurds/text-field 5.3.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.3.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.3.0",
19
- "@purpurds/spinner": "5.3.0",
20
- "@purpurds/label": "5.3.0",
21
- "@purpurds/tokens": "5.3.0",
22
- "@purpurds/icon": "5.3.0",
23
- "@purpurds/field-helper-text": "5.3.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",
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,18 +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"> & {
20
+ type TextFieldBaseProps = {
19
21
  ["data-testid"]?: string;
20
22
  className?: string;
23
+
21
24
  /**
22
25
  * Use to display e.g. a button after the text field.
23
26
  *
@@ -46,6 +49,7 @@ export type TextFieldProps = ComponentPropsWithoutRef<"input"> & {
46
49
  * Use to render a spinner at the end inside of the text field.
47
50
  */
48
51
  loading?: boolean;
52
+
49
53
  /**
50
54
  * Use to display e.g. an icon at the start inside of the text field.
51
55
  *
@@ -66,18 +70,47 @@ export type TextFieldProps = ComponentPropsWithoutRef<"input"> & {
66
70
  valid?: boolean;
67
71
  };
68
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);
69
100
  const rootClassName = "purpur-text-field";
70
101
 
71
102
  const TextFieldComponent = (
72
103
  {
73
104
  ["data-testid"]: dataTestId,
74
105
  className,
106
+ clearButtonAllyLabel,
75
107
  afterField,
76
108
  endAdornment,
77
109
  errorText,
78
110
  helperText,
79
111
  label,
80
112
  loading = false,
113
+ onClear,
81
114
  startAdornment,
82
115
  valid = false,
83
116
  ...inputProps
@@ -89,8 +122,32 @@ const TextFieldComponent = (
89
122
  const getTestId = (name: string) => (dataTestId ? `${dataTestId}-${name}` : undefined);
90
123
  const isValid = valid && !errorText;
91
124
  const helperTextId = helperText ? `${inputId}-helper-text` : undefined;
92
-
93
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
+ };
94
151
 
95
152
  const endAdornments: ReactNode[] = [
96
153
  loading && (
@@ -102,46 +159,57 @@ const TextFieldComponent = (
102
159
  />
103
160
  ),
104
161
  isValid && (
105
- <Icon
162
+ <IconCheckCircleFilled
106
163
  key="valid-icon"
107
164
  data-testid={getTestId("valid-icon")}
108
- className={styles[`${rootClassName}__valid-icon`]}
109
- svg={checkCircleFilled}
110
- size="md"
165
+ className={cx(`${rootClassName}__valid-icon`)}
111
166
  />
112
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
+ ),
113
181
  endAdornment,
114
182
  ].filter((adornment) => !!adornment);
115
183
 
116
- const inputContainerClassnames = c([
117
- styles[`${rootClassName}__input-container`],
184
+ const inputContainerClassnames = cx([
185
+ `${rootClassName}__input-container`,
118
186
  {
119
- [styles[`${rootClassName}__input-container--start-adornment`]]: startAdornments.length,
120
- [styles[`${rootClassName}__input-container--end-adornment`]]: endAdornments.length,
121
- [styles[`${rootClassName}__input-container--disabled`]]: inputProps.disabled,
122
- [styles[`${rootClassName}__input-container--readonly`]]:
123
- 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,
124
192
  },
125
193
  ]);
126
194
 
127
195
  return (
128
- <div className={c(className, styles[rootClassName])}>
196
+ <div className={cx(className, rootClassName)}>
129
197
  {label && (
130
198
  <Label
131
199
  htmlFor={inputId}
132
- className={styles[`${rootClassName}__label`]}
200
+ className={cx(`${rootClassName}__label`)}
133
201
  data-testid={getTestId("label")}
134
202
  disabled={inputProps.disabled}
135
203
  >
136
204
  {`${inputProps.required ? "* " : ""}${label}`}
137
205
  </Label>
138
206
  )}
139
- <div className={styles[`${rootClassName}__field-row`]}>
207
+ <div className={cx(`${rootClassName}__field-row`)}>
140
208
  <div className={inputContainerClassnames}>
141
209
  {!!startAdornments.length && (
142
210
  <div
143
211
  data-testid={getTestId("start-adornments")}
144
- className={styles[`${rootClassName}__adornment-container`]}
212
+ className={cx(`${rootClassName}__adornment-container`)}
145
213
  >
146
214
  {startAdornments}
147
215
  </div>
@@ -149,23 +217,23 @@ const TextFieldComponent = (
149
217
  <input
150
218
  {...inputProps}
151
219
  id={inputId}
152
- ref={ref}
220
+ ref={setRef}
153
221
  data-testid={getTestId("input")}
154
222
  aria-describedby={inputProps["aria-describedby"] || helperTextId}
155
223
  aria-invalid={inputProps["aria-invalid"] || !!errorText}
156
- className={c([
157
- styles[`${rootClassName}__input`],
224
+ className={cx([
225
+ `${rootClassName}__input`,
158
226
  {
159
- [styles[`${rootClassName}__input--valid`]]: isValid,
160
- [styles[`${rootClassName}__input--error`]]: !!errorText,
227
+ [`${rootClassName}__input--valid`]: isValid,
228
+ [`${rootClassName}__input--error`]: !!errorText,
161
229
  },
162
230
  ])}
163
231
  />
164
- <div className={styles[`${rootClassName}__frame`]} />
232
+ <div className={cx(`${rootClassName}__frame`)} />
165
233
  {!!endAdornments.length && (
166
234
  <div
167
235
  data-testid={getTestId("end-adornments")}
168
- className={styles[`${rootClassName}__adornment-container`]}
236
+ className={cx(`${rootClassName}__adornment-container`)}
169
237
  >
170
238
  {endAdornments}
171
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
+ };