@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.
- package/CHANGELOG.md +11 -0
- package/dist/cjs/index.js +62 -0
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/index.js +62 -0
- package/dist/types/src/components/MaskedField/MaskedField.d.ts +14 -0
- package/dist/types/src/components/MaskedField/MaskedField.stories.d.ts +7 -0
- package/dist/types/src/components/MaskedField/MaskedField.test.d.ts +1 -0
- package/dist/types/src/components/MaskedField/index.d.ts +1 -0
- package/dist/types/src/components/index.d.ts +1 -0
- package/package.json +4 -3
- package/src/components/MaskedField/MaskedField.mdx +209 -0
- package/src/components/MaskedField/MaskedField.stories.tsx +53 -0
- package/src/components/MaskedField/MaskedField.test.tsx +350 -0
- package/src/components/MaskedField/MaskedField.tsx +92 -0
- package/src/components/MaskedField/index.tsx +1 -0
- package/src/components/index.tsx +1 -0
|
@@ -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";
|
package/src/components/index.tsx
CHANGED