@simplybusiness/mobius 6.1.1 → 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,209 @@
1
+ import { Meta, ArgTypes, Canvas } from "@storybook/addon-docs/blocks";
2
+ import * as MaskedFieldStories from "./MaskedField.stories";
3
+ import { MaskedField } from "./MaskedField";
4
+
5
+ <Meta of={MaskedFieldStories} />
6
+
7
+ # MaskedField
8
+
9
+ The `MaskedField` component is a form input field that automatically formats user input to match supplied patterns. It supports e.g. phone number formatting, comma-separated numbers, and custom mask patterns to ensure consistent data entry.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ yarn add @simplybusiness/mobius
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```js
20
+ import { MaskedField } from "@simplybusiness/mobius";
21
+ ```
22
+
23
+ ### Phone Number
24
+
25
+ <Canvas of={MaskedFieldStories.PhoneNumber} />
26
+
27
+ ### Comma Separated Number
28
+
29
+ <Canvas of={MaskedFieldStories.CommaSeparatedNumber} />
30
+
31
+ ## Props
32
+
33
+ The `MaskedField` component accepts all props from the `TextField` component, plus the following specific props:
34
+
35
+ ### `mask` (required)
36
+
37
+ **Type:** `ReactMaskOpts` (from react-imask)
38
+
39
+ The mask configuration that defines how the input should be formatted. This can be:
40
+
41
+ - **String pattern**: A string using placeholders like `(000) 000-0000` for phone numbers
42
+ - **RegExp**: A regular expression pattern
43
+ - **Function**: A custom masking function
44
+ - **Number**: For numeric inputs with formatting options
45
+ - **Date**: For date inputs
46
+ - **Object**: Configuration object with mask options
47
+
48
+ For comprehensive documentation on mask configuration options, see the [IMask.js guide](https://imask.js.org/guide.html).
49
+
50
+ #### Number Masks
51
+
52
+ Number masks are used for formatting numeric inputs with various options for decimal places, thousands separators, and value constraints.
53
+
54
+ **Basic Example:**
55
+ ```js
56
+ const numberMask = {
57
+ mask: Number,
58
+ thousandsSeparator: ",",
59
+ scale: 0, // Number of decimal places
60
+ signed: false // Allow negative numbers
61
+ };
62
+ ```
63
+
64
+ **Advanced Example:**
65
+ ```js
66
+ const advancedNumberMask = {
67
+ mask: Number,
68
+ scale: 2, // digits after point, 0 for integers
69
+ thousandsSeparator: ',', // any single char
70
+ padFractionalZeros: false, // if true, pads zeros at end to the length of scale
71
+ normalizeZeros: true, // appends or removes zeros at ends
72
+ radix: '.', // fractional delimiter
73
+ mapToRadix: ['.'], // symbols to process as radix
74
+ min: -10000, // minimum value
75
+ max: 10000, // maximum value
76
+ autofix: true // automatically fix values that exceed min/max
77
+ };
78
+ ```
79
+
80
+ **Available Options:**
81
+ - **`scale`** (number): Digits after the decimal point (0 for integers only)
82
+ - **`thousandsSeparator`** (string): Character to use as thousands separator (e.g., ',' or ' ')
83
+ - **`padFractionalZeros`** (boolean): If true, pads zeros at the end to match the scale length
84
+ - **`normalizeZeros`** (boolean): Appends or removes zeros at the beginning/end
85
+ - **`radix`** (string): Character used as decimal separator (e.g., '.' or ',')
86
+ - **`mapToRadix`** (array): Array of characters that should be treated as the radix
87
+ - **`min`** (number): Minimum allowed value
88
+ - **`max`** (number): Maximum allowed value
89
+ - **`autofix`** (boolean): Automatically fix values that exceed min/max bounds
90
+ - **`signed`** (boolean): Allow negative numbers
91
+
92
+ For more number masking options, see: https://imask.js.org/guide.html#masked-number
93
+
94
+ #### Pattern Masks
95
+
96
+ Pattern masks use special characters to define input structure with placeholders and custom definitions.
97
+
98
+ **Basic Example:**
99
+ ```js
100
+ const phoneNumberMask = {
101
+ mask: "(000) 000-0000"
102
+ };
103
+ ```
104
+
105
+ **Custom Definitions Example:**
106
+ ```js
107
+ const customMask = {
108
+ mask: "AA-0000", // A = letter, 0 = digit
109
+ definitions: {
110
+ 'A': /[A-Z]/,
111
+ '0': /\d/
112
+ }
113
+ };
114
+ ```
115
+
116
+ **Advanced Pattern Example:**
117
+ ```js
118
+ const patternMaskWithPlaceholder = {
119
+ mask: '+{7}(000)000-00-00',
120
+ lazy: false, // make placeholder always visible
121
+ placeholderChar: '#' // defaults to '_'
122
+ };
123
+ ```
124
+
125
+ **Secure Input Example:**
126
+ ```js
127
+ const secureMask = {
128
+ mask: '000000',
129
+ displayChar: '#', // character to display for entered digits
130
+ lazy: false,
131
+ overwrite: 'shift'
132
+ };
133
+ ```
134
+
135
+ **Built-in Definitions:**
136
+ - **`0`** - any digit
137
+ - **`a`** - any letter
138
+ - **`*`** - any character
139
+ - **`[]`** - make input optional (e.g., `000[000]` makes last 3 digits optional)
140
+ - **`{}`** - include fixed part in unmasked value
141
+ - **`` ` ``** - prevent symbols from shifting back
142
+ - **`\\`** - escape character to treat definition chars as fixed (e.g., `\\0` for literal "0")
143
+
144
+ **Configuration Options:**
145
+ - **`definitions`** (object): Custom character definitions (e.g., `{ '#': /[1-6]/ }`)
146
+ - **`lazy`** (boolean): When false, shows placeholder immediately; when true, shows on focus
147
+ - **`placeholderChar`** (string): Character to use for placeholder (defaults to '_')
148
+ - **`displayChar`** (string): Character to display for entered values (for secure input)
149
+ - **`overwrite`** (string|boolean): Controls overwrite behavior ('shift', true, false)
150
+
151
+ For more pattern masking options, see: https://imask.js.org/guide.html#masked-pattern
152
+
153
+ #### Date Masks
154
+
155
+ Date masks provide structured date input with validation and formatting.
156
+
157
+ **Example:**
158
+ ```js
159
+ const dateMask = {
160
+ mask: Date,
161
+ pattern: 'MM/DD/YYYY',
162
+ min: new Date(1900, 0, 1),
163
+ max: new Date(2100, 0, 1)
164
+ };
165
+ ```
166
+
167
+ ### `useMaskedValue` (optional)
168
+
169
+ **Type:** `boolean`
170
+ **Default:** `false`
171
+
172
+ Controls whether the `onChange` callback receives the masked (formatted) value or the unmasked (raw) value.
173
+
174
+ - **`false` (default)**: Returns the unmasked value (e.g., "1234567890" for a phone number)
175
+ - **`true`**: Returns the masked value (e.g., "(123) 456-7890" for a phone number)
176
+
177
+ #### Example:
178
+
179
+ ```js
180
+ // With useMaskedValue=false (default)
181
+ <MaskedField
182
+ mask={{ mask: "(000) 000-0000" }}
183
+ onChange={(e) => console.log(e.target.value)} // Logs: "1234567890"
184
+ />
185
+
186
+ // With useMaskedValue=true
187
+ <MaskedField
188
+ mask={{ mask: "(000) 000-0000" }}
189
+ useMaskedValue={true}
190
+ onChange={(e) => console.log(e.target.value)} // Logs: "(123) 456-7890"
191
+ />
192
+ ```
193
+
194
+ <ArgTypes of={MaskedField} />
195
+
196
+ ## Component HTML Structure and Class names
197
+
198
+ The following HTML is rendered for a MaskedField:
199
+
200
+ ```html
201
+ <div class="mobius mobius-text-field">
202
+ <label class="mobius-text-field__label">
203
+ <!-- Label text -->
204
+ </label>
205
+ <input class="mobius-text-field__input" type="text" />
206
+ </div>
207
+ ```
208
+
209
+ The MaskedField component extends the TextField component and inherits its CSS classes and structure.
@@ -0,0 +1,53 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import type { MaskedFieldProps } from ".";
3
+ import { MaskedField } from ".";
4
+ import { excludeControls } from "../../utils";
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
+ type StoryType = StoryObj<typeof MaskedField>;
18
+
19
+ const meta: Meta<typeof MaskedField> = {
20
+ title: "Forms/MaskedField",
21
+ component: MaskedField,
22
+ argTypes: excludeControls("className"),
23
+ };
24
+
25
+ export const PhoneNumber: StoryType = {
26
+ render: (args: MaskedFieldProps) => (
27
+ <MaskedField
28
+ {...args}
29
+ mask={usPhoneNumberMask}
30
+ placeholder="Enter phone number"
31
+ label="Phone Number"
32
+ />
33
+ ),
34
+ args: {
35
+ mask: usPhoneNumberMask,
36
+ },
37
+ };
38
+
39
+ export const CommaSeparatedNumber: StoryType = {
40
+ render: (args: MaskedFieldProps) => (
41
+ <MaskedField
42
+ {...args}
43
+ mask={commaSeparatedNumberMask}
44
+ placeholder="Enter number"
45
+ label="Number"
46
+ />
47
+ ),
48
+ args: {
49
+ mask: commaSeparatedNumberMask,
50
+ },
51
+ };
52
+
53
+ export default meta;
@@ -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";