@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.
- package/CHANGELOG.md +21 -0
- package/dist/cjs/index.js +197 -105
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/index.js +122 -31
- package/dist/types/src/components/Drawer/types.d.ts +1 -0
- package/dist/types/src/components/Drawer/useDrawer.d.ts +1 -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/NumberField/NumberField.d.ts +1 -1
- package/dist/types/src/components/index.d.ts +1 -0
- package/package.json +4 -3
- package/src/components/Drawer/Drawer.tsx +5 -2
- package/src/components/Drawer/DrawerContext.tsx +1 -0
- package/src/components/Drawer/Header.tsx +2 -2
- package/src/components/Drawer/types.ts +1 -0
- package/src/components/Drawer/useDrawer.ts +2 -2
- 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/NumberField/NumberField.tsx +42 -2
- package/src/components/Radio/Radio.test.tsx +1 -1
- package/src/components/Radio/RadioGroup.tsx +0 -9
- package/src/components/index.tsx +1 -0
|
@@ -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";
|