@simplybusiness/mobius 6.1.2 → 6.2.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,350 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { MaskedField } from ".";
5
+
6
+ const commaSeparatedNumberMask = {
7
+ mask: Number,
8
+ thousandsSeparator: ",",
9
+ scale: 0,
10
+ signed: false,
11
+ };
12
+
13
+ const usPhoneNumberMask = {
14
+ mask: "(000) 000-0000",
15
+ };
16
+
17
+ const WRAPPER_CLASS_NAME = "mobius-text-field";
18
+ const INPUT_CLASS_NAME = "mobius-text-field__input";
19
+
20
+ describe("MaskedField", () => {
21
+ it("should render without errors", () => {
22
+ render(
23
+ <MaskedField
24
+ mask={usPhoneNumberMask}
25
+ label="Phone Number"
26
+ data-testid="masked-field"
27
+ />,
28
+ );
29
+ expect(screen.getByTestId("masked-field")).toBeInTheDocument();
30
+ });
31
+
32
+ it("should render with correct base class names", () => {
33
+ const { container } = render(
34
+ <MaskedField
35
+ mask={usPhoneNumberMask}
36
+ label="Phone Number"
37
+ data-testid="masked-field"
38
+ />,
39
+ );
40
+
41
+ expect(container.firstChild).toHaveClass("mobius");
42
+ expect(container.firstChild).toHaveClass(WRAPPER_CLASS_NAME);
43
+ expect(screen.getByTestId("masked-field")).toHaveClass("mobius");
44
+ expect(screen.getByTestId("masked-field")).toHaveClass(INPUT_CLASS_NAME);
45
+ });
46
+
47
+ it("should apply phone number mask correctly", async () => {
48
+ const user = userEvent.setup();
49
+
50
+ render(
51
+ <MaskedField
52
+ mask={usPhoneNumberMask}
53
+ label="Phone Number"
54
+ data-testid="masked-field"
55
+ />,
56
+ );
57
+
58
+ const input = screen.getByTestId("masked-field");
59
+
60
+ await user.type(input, "1234567890");
61
+
62
+ expect(input).toHaveValue("(123) 456-7890");
63
+ });
64
+
65
+ it("should apply comma-separated number mask correctly", async () => {
66
+ const user = userEvent.setup();
67
+
68
+ render(
69
+ <MaskedField
70
+ mask={commaSeparatedNumberMask}
71
+ label="Number"
72
+ data-testid="masked-field"
73
+ />,
74
+ );
75
+
76
+ const input = screen.getByTestId("masked-field");
77
+
78
+ await user.type(input, "1234567");
79
+
80
+ expect(input).toHaveValue("1,234,567");
81
+ });
82
+
83
+ describe("onChange behavior", () => {
84
+ it("should call onChange with unmasked value by default", async () => {
85
+ const user = userEvent.setup();
86
+ const mockOnChange = jest.fn();
87
+
88
+ render(
89
+ <MaskedField
90
+ mask={usPhoneNumberMask}
91
+ label="Phone Number"
92
+ data-testid="masked-field"
93
+ onChange={mockOnChange}
94
+ />,
95
+ );
96
+
97
+ const input = screen.getByTestId("masked-field");
98
+
99
+ await user.type(input, "1234567890");
100
+
101
+ // Should be called with the unmasked value
102
+ expect(mockOnChange).toHaveBeenCalledWith(
103
+ expect.objectContaining({
104
+ target: expect.objectContaining({
105
+ value: "1234567890",
106
+ }),
107
+ }),
108
+ );
109
+ });
110
+
111
+ it("should call onChange with masked value when useMaskedValue is true", async () => {
112
+ const user = userEvent.setup();
113
+ const mockOnChange = jest.fn();
114
+
115
+ render(
116
+ <MaskedField
117
+ mask={usPhoneNumberMask}
118
+ label="Phone Number"
119
+ data-testid="masked-field"
120
+ onChange={mockOnChange}
121
+ useMaskedValue={true}
122
+ />,
123
+ );
124
+
125
+ const input = screen.getByTestId("masked-field");
126
+
127
+ await user.type(input, "1234567890");
128
+
129
+ // Should be called with the masked value
130
+ expect(mockOnChange).toHaveBeenCalledWith(
131
+ expect.objectContaining({
132
+ target: expect.objectContaining({
133
+ value: "(123) 456-7890",
134
+ }),
135
+ }),
136
+ );
137
+ });
138
+
139
+ it("should include name in onChange event when provided", async () => {
140
+ const user = userEvent.setup();
141
+ const mockOnChange = jest.fn();
142
+
143
+ render(
144
+ <MaskedField
145
+ mask={usPhoneNumberMask}
146
+ label="Phone Number"
147
+ data-testid="masked-field"
148
+ name="phoneNumber"
149
+ onChange={mockOnChange}
150
+ />,
151
+ );
152
+
153
+ const input = screen.getByTestId("masked-field");
154
+
155
+ await user.type(input, "123");
156
+
157
+ expect(mockOnChange).toHaveBeenCalledWith(
158
+ expect.objectContaining({
159
+ target: expect.objectContaining({
160
+ name: "phoneNumber",
161
+ }),
162
+ currentTarget: expect.objectContaining({
163
+ name: "phoneNumber",
164
+ }),
165
+ }),
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("controlled component behavior", () => {
171
+ it("should accept value prop without errors", () => {
172
+ // This test verifies that the component can accept a value prop
173
+ // without throwing errors, even if the useIMask hook doesn't
174
+ // immediately format it
175
+ render(
176
+ <MaskedField
177
+ mask={usPhoneNumberMask}
178
+ label="Phone Number"
179
+ data-testid="masked-field"
180
+ value="9876543210"
181
+ />,
182
+ );
183
+
184
+ const input = screen.getByTestId("masked-field");
185
+ expect(input).toBeInTheDocument();
186
+ });
187
+
188
+ it("should work with defaultValue prop", () => {
189
+ render(
190
+ <MaskedField
191
+ mask={usPhoneNumberMask}
192
+ label="Phone Number"
193
+ data-testid="masked-field"
194
+ defaultValue="1234567890"
195
+ />,
196
+ );
197
+
198
+ const input = screen.getByTestId("masked-field");
199
+ expect(input).toHaveValue("(123) 456-7890");
200
+ });
201
+ });
202
+
203
+ describe("accessibility", () => {
204
+ it("should forward aria-label correctly", () => {
205
+ render(
206
+ <MaskedField
207
+ mask={usPhoneNumberMask}
208
+ label="Phone Number"
209
+ data-testid="masked-field"
210
+ aria-label="Custom phone number label"
211
+ />,
212
+ );
213
+
214
+ const input = screen.getByTestId("masked-field");
215
+ expect(input).toHaveAttribute("aria-label", "Custom phone number label");
216
+ });
217
+
218
+ it("should forward aria-describedby correctly", () => {
219
+ render(
220
+ <MaskedField
221
+ mask={usPhoneNumberMask}
222
+ label="Phone Number"
223
+ data-testid="masked-field"
224
+ aria-describedby="phone-help"
225
+ />,
226
+ );
227
+
228
+ const input = screen.getByTestId("masked-field");
229
+ expect(input).toHaveAttribute("aria-describedby", "phone-help");
230
+ });
231
+ });
232
+
233
+ describe("ref forwarding", () => {
234
+ it("should forward ref to the input element", () => {
235
+ let inputRef: HTMLInputElement | null = null;
236
+
237
+ render(
238
+ <MaskedField
239
+ mask={usPhoneNumberMask}
240
+ label="Phone Number"
241
+ data-testid="masked-field"
242
+ ref={ref => {
243
+ inputRef = ref;
244
+ }}
245
+ />,
246
+ );
247
+
248
+ const input = screen.getByTestId("masked-field");
249
+ expect(inputRef).toBe(input);
250
+ });
251
+
252
+ it("should work with useRef", () => {
253
+ const TestComponent = () => {
254
+ const inputRef = React.useRef<HTMLInputElement>(null);
255
+
256
+ return (
257
+ <MaskedField
258
+ mask={usPhoneNumberMask}
259
+ label="Phone Number"
260
+ data-testid="masked-field"
261
+ ref={inputRef}
262
+ />
263
+ );
264
+ };
265
+
266
+ render(<TestComponent />);
267
+
268
+ const input = screen.getByTestId("masked-field");
269
+ expect(input).toBeInTheDocument();
270
+ });
271
+ });
272
+
273
+ describe("custom mask configurations", () => {
274
+ it("should work with custom mask objects", async () => {
275
+ const user = userEvent.setup();
276
+ const customMask = {
277
+ mask: "000-000",
278
+ definitions: {
279
+ "0": /[0-9]/,
280
+ },
281
+ };
282
+
283
+ render(
284
+ <MaskedField
285
+ mask={customMask}
286
+ label="Custom Code"
287
+ data-testid="masked-field"
288
+ />,
289
+ );
290
+
291
+ const input = screen.getByTestId("masked-field");
292
+
293
+ await user.type(input, "123456");
294
+
295
+ expect(input).toHaveValue("123-456");
296
+ });
297
+ });
298
+
299
+ describe("edge cases", () => {
300
+ it("should handle empty input gracefully", () => {
301
+ render(
302
+ <MaskedField
303
+ mask={usPhoneNumberMask}
304
+ label="Phone Number"
305
+ data-testid="masked-field"
306
+ />,
307
+ );
308
+
309
+ const input = screen.getByTestId("masked-field");
310
+ expect(input).toHaveValue("");
311
+ });
312
+
313
+ it("should not call onChange when no onChange handler is provided", async () => {
314
+ const user = userEvent.setup();
315
+
316
+ render(
317
+ <MaskedField
318
+ mask={usPhoneNumberMask}
319
+ label="Phone Number"
320
+ data-testid="masked-field"
321
+ />,
322
+ );
323
+
324
+ const input = screen.getByTestId("masked-field");
325
+
326
+ // Should not throw an error
327
+ await user.type(input, "123");
328
+
329
+ expect(input).toHaveValue("(123");
330
+ });
331
+
332
+ it("should handle partial input correctly", async () => {
333
+ const user = userEvent.setup();
334
+
335
+ render(
336
+ <MaskedField
337
+ mask={usPhoneNumberMask}
338
+ label="Phone Number"
339
+ data-testid="masked-field"
340
+ />,
341
+ );
342
+
343
+ const input = screen.getByTestId("masked-field");
344
+
345
+ await user.type(input, "123");
346
+
347
+ expect(input).toHaveValue("(123");
348
+ });
349
+ });
350
+ });
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ import type { Ref, RefAttributes } from "react";
4
+ import { forwardRef } from "react";
5
+ import type { ReactMaskOpts } from "react-imask";
6
+ import { useIMask } from "react-imask";
7
+ import type { DOMProps } from "../../types/dom";
8
+ import type { ForwardedRefComponent } from "../../types/components";
9
+ import { TextField, type TextFieldProps } from "../TextField";
10
+
11
+ export type MaskedFieldElementType = HTMLInputElement;
12
+
13
+ export interface MaskedFieldProps
14
+ extends Omit<TextFieldProps, "type">,
15
+ DOMProps,
16
+ RefAttributes<MaskedFieldElementType> {
17
+ /** The mask configuration to apply */
18
+ mask: ReactMaskOpts;
19
+ /** Whether to return the masked (formatted) value in onChange. Defaults to false (unmasked value) */
20
+ useMaskedValue?: boolean;
21
+ }
22
+
23
+ export type MaskedFieldRef = Ref<MaskedFieldElementType>;
24
+
25
+ export const MaskedField: ForwardedRefComponent<
26
+ MaskedFieldProps,
27
+ MaskedFieldElementType
28
+ > = forwardRef((props: MaskedFieldProps, ref: MaskedFieldRef) => {
29
+ const {
30
+ mask,
31
+ value,
32
+ defaultValue,
33
+ useMaskedValue = false,
34
+ onChange,
35
+ "aria-describedby": ariaDescribedBy,
36
+ "aria-label": ariaLabel,
37
+ ...textFieldProps
38
+ } = props;
39
+
40
+ // Use the useIMask hook to get the mask reference and all value types
41
+ const { ref: maskRef, value: maskedValue } = useIMask(mask, {
42
+ defaultValue,
43
+ onAccept: (value, maskRef) => {
44
+ // Only trigger onChange when we have a valid masked input
45
+ if (onChange) {
46
+ // Determine which value to use based on useMaskedValue
47
+ const valueToEmit = useMaskedValue ? value : maskRef.unmaskedValue;
48
+
49
+ // Create a synthetic event that mimics a ChangeEvent
50
+ const syntheticEvent = {
51
+ target: {
52
+ value: valueToEmit,
53
+ name: textFieldProps.name,
54
+ },
55
+ currentTarget: {
56
+ value: valueToEmit,
57
+ name: textFieldProps.name,
58
+ },
59
+ } as React.ChangeEvent<HTMLInputElement>;
60
+
61
+ onChange(syntheticEvent);
62
+ }
63
+ },
64
+ });
65
+
66
+ // Create a composite ref that handles both the mask ref and the forwarded ref
67
+ const inputRef = (node: HTMLInputElement | null) => {
68
+ if (maskRef) {
69
+ maskRef.current = node;
70
+ }
71
+ if (ref) {
72
+ if (typeof ref === "function") {
73
+ ref(node);
74
+ } else {
75
+ ref.current = node;
76
+ }
77
+ }
78
+ };
79
+
80
+ return (
81
+ <TextField
82
+ {...textFieldProps}
83
+ ref={inputRef}
84
+ value={maskedValue}
85
+ onChange={() => {}} // No-op to satisfy React's controlled component requirement, onChange is handled by the onAccept callback in useIMask
86
+ aria-describedby={ariaDescribedBy}
87
+ aria-label={ariaLabel}
88
+ />
89
+ );
90
+ });
91
+
92
+ MaskedField.displayName = "MaskedField";
@@ -0,0 +1 @@
1
+ export * from "./MaskedField";
@@ -45,3 +45,4 @@ export * from "./Title";
45
45
  export * from "./Trust";
46
46
  export * from "./ExpandableText";
47
47
  export * from "./VisuallyHidden";
48
+ export * from "./MaskedField";