@simplybusiness/mobius 5.26.3 → 5.27.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 +6 -0
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/types/src/components/DateField/DateField.d.ts +13 -0
- package/dist/types/src/components/DateField/DateField.stories.d.ts +13 -0
- package/dist/types/src/components/DateField/DateField.test.d.ts +1 -0
- package/dist/types/src/components/DateField/index.d.ts +1 -0
- package/dist/types/src/components/DateField/validation.d.ts +2 -0
- package/dist/types/src/components/DateField/validation.test.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/DateField/DateField.mdx +113 -0
- package/src/components/DateField/DateField.stories.tsx +120 -0
- package/src/components/DateField/DateField.test.tsx +238 -0
- package/src/components/DateField/DateField.tsx +130 -0
- package/src/components/DateField/index.tsx +1 -0
- package/src/components/DateField/validation.test.ts +82 -0
- package/src/components/DateField/validation.ts +43 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RefAttributes } from "react";
|
|
2
|
+
import { type TextFieldElementType, type TextFieldProps, type TextFieldRef } from "../TextField";
|
|
3
|
+
export interface DateFieldProps extends Omit<TextFieldProps, "type">, RefAttributes<TextFieldElementType> {
|
|
4
|
+
/** The earliest date allowed for the input. */
|
|
5
|
+
min?: string;
|
|
6
|
+
/** The latest date allowed for the input. */
|
|
7
|
+
max?: string;
|
|
8
|
+
/** Date format to use. */
|
|
9
|
+
format?: string;
|
|
10
|
+
}
|
|
11
|
+
export type DateFieldRef = TextFieldRef;
|
|
12
|
+
export declare const MIN_MAX_ERROR = "\"min\" value should not be greater than \"max\" value.";
|
|
13
|
+
export declare const DateField: import("react").ForwardRefExoticComponent<Omit<DateFieldProps, "ref"> & RefAttributes<HTMLInputElement>>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { DateField } from "./DateField";
|
|
3
|
+
type StoryType = StoryObj<typeof DateField>;
|
|
4
|
+
declare const meta: Meta<typeof DateField>;
|
|
5
|
+
export declare const Default: StoryType;
|
|
6
|
+
export declare const DefaultValue: StoryType;
|
|
7
|
+
export declare const WithErrorMessage: StoryType;
|
|
8
|
+
export declare const LimitedDates: StoryType;
|
|
9
|
+
export declare const LimitedDatesWithCustomFormat: StoryType;
|
|
10
|
+
export declare const InvalidMin: StoryType;
|
|
11
|
+
export declare const InvalidMax: StoryType;
|
|
12
|
+
export declare const MaxBeforeMin: StoryType;
|
|
13
|
+
export default meta;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./DateField";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ArgTypes, Canvas, Meta } from "@storybook/blocks";
|
|
2
|
+
import { DateField } from "./DateField";
|
|
3
|
+
import * as DateFieldStories from "./DateField.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={DateFieldStories} />
|
|
6
|
+
|
|
7
|
+
# DateField
|
|
8
|
+
|
|
9
|
+
DateField allows a user to input a Date. It includes a show/hide button on the right hand side.
|
|
10
|
+
|
|
11
|
+
Dates are displayed in the user's locale format, e.g. `DD/MM/YYYY` or `MM/DD/YYYY`. The input field is a native HTML5 date input, which means it will show a date picker on supported browsers.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
yarn add @simplybusiness/mobius
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import { DateField } from "@simplybusiness/mobius";
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Examples
|
|
26
|
+
|
|
27
|
+
### Default
|
|
28
|
+
|
|
29
|
+
<Canvas of={DateFieldStories.Default} />
|
|
30
|
+
|
|
31
|
+
## Default value
|
|
32
|
+
|
|
33
|
+
Dates are formatted internally in `yyyy-mm-dd` format. The `defaultValue` prop can be used to set the initial value of the input field. It is expected in the format `yyyy-mm-dd`; if you want to use a different format, you can use the `format` prop to specify a custom format.
|
|
34
|
+
|
|
35
|
+
_NOTE: If you set a custom format, then you must use it for all other props (`defaultValue`, `min`, `max`, etc.) that expect a date. The component can only support one format._
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
import { DateField } from "@simplybusiness/mobius";
|
|
39
|
+
|
|
40
|
+
// Fixed date
|
|
41
|
+
<DateField defaultValue="2023-10-01" />;
|
|
42
|
+
|
|
43
|
+
// Today's date
|
|
44
|
+
<DateField defaultValue={new Date().toISOString().split("T")[0]} />;
|
|
45
|
+
|
|
46
|
+
// Custom date format for UK
|
|
47
|
+
<DateField defaultValue="01/10/2023" format="dd/mm/yyyy" />;
|
|
48
|
+
|
|
49
|
+
// Custom date format for US
|
|
50
|
+
<DateField defaultValue="10/01/2023" format="mm/dd/yyyy" />;
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
<Canvas of={DateFieldStories.DefaultValue} />
|
|
54
|
+
|
|
55
|
+
## Limiting input with min and max
|
|
56
|
+
|
|
57
|
+
The default format for `min` and `max` props is `yyyy-mm-dd`. Min and max props can be used to limit the range of dates that can be selected. The input field will not allow dates outside of this range.
|
|
58
|
+
|
|
59
|
+
It is possible to set the min and max props to a date in the future or past. The input field will not allow dates outside of this range.
|
|
60
|
+
|
|
61
|
+
You can use a custom date format by passing a function to the `format` prop. The function should return a string in the format you want.
|
|
62
|
+
|
|
63
|
+
<Canvas of={DateFieldStories.LimitedDates} />
|
|
64
|
+
|
|
65
|
+
<Canvas of={DateFieldStories.LimitedDatesWithCustomFormat} />
|
|
66
|
+
|
|
67
|
+
## Validation
|
|
68
|
+
|
|
69
|
+
The `DateField` component supports validation. The `errorMessage` prop can be used to display error messages. The `isValid` prop can be used to indicate whether the input is valid or not.
|
|
70
|
+
|
|
71
|
+
The component has internal validation for invalid min and max date props, along with logical checks that max > min. The `min` and `max` props should be in the default format `yyyy-mm-dd`, or match a supplied custom `format` prop, to ensure proper validation.
|
|
72
|
+
|
|
73
|
+
<Canvas of={DateFieldStories.InvalidMin} />
|
|
74
|
+
|
|
75
|
+
<Canvas of={DateFieldStories.InvalidMax} />
|
|
76
|
+
|
|
77
|
+
<Canvas of={DateFieldStories.MaxBeforeMin} />
|
|
78
|
+
|
|
79
|
+
## Controlled component
|
|
80
|
+
|
|
81
|
+
The `DateField` component can be used as a controlled component. The value of the input field is controlled by the `value` prop. The `onChange` prop is used to update the value of the input field.
|
|
82
|
+
|
|
83
|
+
## Accessibility
|
|
84
|
+
|
|
85
|
+
It's recommended to pass a `label` prop in order to show a visual label. When the `label` prop is not provided, make sure to provide the `aria-label` prop instead. If the text field is labeled by a separate element, the `aria-labelledby` props must be used with the id of the labeling element.
|
|
86
|
+
|
|
87
|
+
## Events
|
|
88
|
+
|
|
89
|
+
The `onChange` prop can be used to listen to changes of the value of the input. See the prop table for the complete list of events supported.
|
|
90
|
+
See the example of controlled component using the `onChange` prop.
|
|
91
|
+
|
|
92
|
+
## Props
|
|
93
|
+
|
|
94
|
+
<ArgTypes of={DateField} />
|
|
95
|
+
|
|
96
|
+
## Component HTML Structure and Class names
|
|
97
|
+
|
|
98
|
+
The following HTML is rendered for a DateField:
|
|
99
|
+
|
|
100
|
+
```html
|
|
101
|
+
<div class="mobius-date-field">
|
|
102
|
+
<label class="mobius-label">{label}</label>
|
|
103
|
+
<input class="mobius-text-field__input" type="date" />
|
|
104
|
+
<div class="mobius-error-message">{errors}</div>
|
|
105
|
+
</div>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Class names are augmented with the following flags if true:
|
|
109
|
+
|
|
110
|
+
- \--is-disabled
|
|
111
|
+
- \--is-selected
|
|
112
|
+
- \--is-valid
|
|
113
|
+
- \--is-invalid
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { excludeControls } from "../../utils";
|
|
3
|
+
import { StoryContainer } from "../../utils/StoryContainer";
|
|
4
|
+
import type { DateFieldProps } from "./DateField";
|
|
5
|
+
import { DateField } from "./DateField";
|
|
6
|
+
|
|
7
|
+
type StoryType = StoryObj<typeof DateField>;
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof DateField> = {
|
|
10
|
+
title: "Forms/DateField",
|
|
11
|
+
component: DateField,
|
|
12
|
+
argTypes: {
|
|
13
|
+
...excludeControls(
|
|
14
|
+
"description",
|
|
15
|
+
"validationState",
|
|
16
|
+
"type",
|
|
17
|
+
"labelElementType",
|
|
18
|
+
"inputElementType",
|
|
19
|
+
),
|
|
20
|
+
},
|
|
21
|
+
args: {
|
|
22
|
+
isReadOnly: false,
|
|
23
|
+
},
|
|
24
|
+
decorators: [
|
|
25
|
+
Story => (
|
|
26
|
+
<StoryContainer>
|
|
27
|
+
<Story />
|
|
28
|
+
</StoryContainer>
|
|
29
|
+
),
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const Default: StoryType = {
|
|
34
|
+
render: (args: DateFieldProps) => <DateField {...args} />,
|
|
35
|
+
args: {
|
|
36
|
+
label: "Claim date",
|
|
37
|
+
isDisabled: false,
|
|
38
|
+
errorMessage: "",
|
|
39
|
+
isRequired: false,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const DefaultValue: StoryType = {
|
|
44
|
+
render: (args: DateFieldProps) => <DateField {...args} />,
|
|
45
|
+
args: {
|
|
46
|
+
label: "Claim date",
|
|
47
|
+
isDisabled: false,
|
|
48
|
+
defaultValue: "2025-04-01",
|
|
49
|
+
isRequired: true,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const WithErrorMessage: StoryType = {
|
|
54
|
+
render: (args: DateFieldProps) => <DateField {...args} />,
|
|
55
|
+
args: {
|
|
56
|
+
label: "Limited Dates (2025 - dd/mm/yyyy)",
|
|
57
|
+
isRequired: true,
|
|
58
|
+
min: "01/01/2025",
|
|
59
|
+
max: "31/12/2025",
|
|
60
|
+
format: "dd/mm/yyyy",
|
|
61
|
+
errorMessage: "This is a custom error message.",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const LimitedDates: StoryType = {
|
|
66
|
+
render: (args: DateFieldProps) => <DateField {...args} />,
|
|
67
|
+
args: {
|
|
68
|
+
label: "Limited Dates (2025)",
|
|
69
|
+
isRequired: true,
|
|
70
|
+
min: "2025-01-01",
|
|
71
|
+
max: "2025-12-31",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const LimitedDatesWithCustomFormat: StoryType = {
|
|
76
|
+
render: (args: DateFieldProps) => <DateField {...args} />,
|
|
77
|
+
args: {
|
|
78
|
+
label: "Limited Dates (2025 - dd/mm/yyyy)",
|
|
79
|
+
isRequired: true,
|
|
80
|
+
min: "01/01/2025",
|
|
81
|
+
max: "31/12/2025",
|
|
82
|
+
format: "dd/mm/yyyy",
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const InvalidMin: StoryType = {
|
|
87
|
+
render: (args: DateFieldProps) => <DateField {...args} />,
|
|
88
|
+
args: {
|
|
89
|
+
label: "Invalid Min Date",
|
|
90
|
+
isDisabled: false,
|
|
91
|
+
errorMessage: "",
|
|
92
|
+
isRequired: true,
|
|
93
|
+
min: "garbage",
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const InvalidMax: StoryType = {
|
|
98
|
+
render: (args: DateFieldProps) => <DateField {...args} />,
|
|
99
|
+
args: {
|
|
100
|
+
label: "Invalid Max Date",
|
|
101
|
+
isDisabled: false,
|
|
102
|
+
errorMessage: "",
|
|
103
|
+
isRequired: true,
|
|
104
|
+
max: "garbage",
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const MaxBeforeMin: StoryType = {
|
|
109
|
+
render: (args: DateFieldProps) => <DateField {...args} />,
|
|
110
|
+
args: {
|
|
111
|
+
label: "Max before Min",
|
|
112
|
+
isDisabled: false,
|
|
113
|
+
errorMessage: "",
|
|
114
|
+
isRequired: true,
|
|
115
|
+
min: "2023-01-01",
|
|
116
|
+
max: "2022-12-31",
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export default meta;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { DateField, MIN_MAX_ERROR } from "./DateField";
|
|
5
|
+
|
|
6
|
+
describe("DateField", () => {
|
|
7
|
+
it("renders correctly", () => {
|
|
8
|
+
render(<DateField aria-label="date-input" data-testid="custom-test-id" />);
|
|
9
|
+
expect(screen.getByTestId("custom-test-id")).toBeInTheDocument();
|
|
10
|
+
expect(screen.getByTestId("custom-test-id")).toHaveAttribute(
|
|
11
|
+
"type",
|
|
12
|
+
"date",
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("applies data-testid to input element", () => {
|
|
17
|
+
render(<DateField aria-label="date-input" data-testid="custom-test-id" />);
|
|
18
|
+
expect(screen.getByTestId("custom-test-id")).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("applies custom className", () => {
|
|
22
|
+
render(
|
|
23
|
+
<DateField
|
|
24
|
+
className="custom-class"
|
|
25
|
+
aria-label="date-input"
|
|
26
|
+
data-testid="custom-test-id"
|
|
27
|
+
/>,
|
|
28
|
+
);
|
|
29
|
+
expect(screen.getByTestId("custom-test-id")).toHaveClass(
|
|
30
|
+
"mobius-date-field",
|
|
31
|
+
);
|
|
32
|
+
expect(screen.getByTestId("custom-test-id")).toHaveClass("custom-class");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("forwards props to TextField", () => {
|
|
36
|
+
render(
|
|
37
|
+
<DateField
|
|
38
|
+
aria-label="date-input"
|
|
39
|
+
placeholder="Select date"
|
|
40
|
+
isDisabled
|
|
41
|
+
data-testid="custom-test-id"
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
const input = screen.getByTestId("custom-test-id");
|
|
45
|
+
expect(input).toHaveAttribute("placeholder", "Select date");
|
|
46
|
+
expect(input).toBeDisabled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("applies min and max attributes", () => {
|
|
50
|
+
render(
|
|
51
|
+
<DateField
|
|
52
|
+
aria-label="date-input"
|
|
53
|
+
min="2023-01-01"
|
|
54
|
+
max="2023-12-31"
|
|
55
|
+
data-testid="custom-test-id"
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
const input = screen.getByTestId("custom-test-id");
|
|
59
|
+
expect(input).toHaveAttribute("min", "2023-01-01");
|
|
60
|
+
expect(input).toHaveAttribute("max", "2023-12-31");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("formats defaultValue when format is provided", () => {
|
|
64
|
+
render(
|
|
65
|
+
<DateField
|
|
66
|
+
aria-label="date-input"
|
|
67
|
+
defaultValue="01/15/2023"
|
|
68
|
+
format="mm/dd/yyyy"
|
|
69
|
+
data-testid="custom-test-id"
|
|
70
|
+
/>,
|
|
71
|
+
);
|
|
72
|
+
const input = screen.getByTestId("custom-test-id");
|
|
73
|
+
expect(input).toHaveValue("2023-01-15");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("shows error for invalid min date", () => {
|
|
77
|
+
render(
|
|
78
|
+
<DateField
|
|
79
|
+
aria-label="date-input"
|
|
80
|
+
min="invalid-date"
|
|
81
|
+
data-testid="custom-test-id"
|
|
82
|
+
/>,
|
|
83
|
+
);
|
|
84
|
+
expect(screen.getByText(/Invalid min date/)).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("shows error for invalid max date", () => {
|
|
88
|
+
render(
|
|
89
|
+
<DateField
|
|
90
|
+
aria-label="date-input"
|
|
91
|
+
max="invalid-date"
|
|
92
|
+
data-testid="custom-test-id"
|
|
93
|
+
/>,
|
|
94
|
+
);
|
|
95
|
+
expect(screen.getByText(/Invalid max date/)).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("shows error when min is greater than max", () => {
|
|
99
|
+
render(
|
|
100
|
+
<DateField
|
|
101
|
+
aria-label="date-input"
|
|
102
|
+
min="2023-12-31"
|
|
103
|
+
max="2023-01-01"
|
|
104
|
+
data-testid="custom-test-id"
|
|
105
|
+
/>,
|
|
106
|
+
);
|
|
107
|
+
expect(screen.getByText(MIN_MAX_ERROR)).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("accepts valid min and max values", () => {
|
|
111
|
+
render(
|
|
112
|
+
<DateField
|
|
113
|
+
aria-label="date-input"
|
|
114
|
+
min="2023-01-01"
|
|
115
|
+
max="2023-12-31"
|
|
116
|
+
data-testid="custom-test-id"
|
|
117
|
+
/>,
|
|
118
|
+
);
|
|
119
|
+
// Should not show error messages
|
|
120
|
+
expect(screen.queryByText(/Invalid/)).not.toBeInTheDocument();
|
|
121
|
+
expect(screen.queryByText(MIN_MAX_ERROR)).not.toBeInTheDocument();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("passes ref to the underlying input", () => {
|
|
125
|
+
const ref = React.createRef<HTMLInputElement>();
|
|
126
|
+
render(
|
|
127
|
+
<DateField
|
|
128
|
+
aria-label="date-input"
|
|
129
|
+
ref={ref}
|
|
130
|
+
data-testid="custom-test-id"
|
|
131
|
+
/>,
|
|
132
|
+
);
|
|
133
|
+
expect(ref.current).not.toBeNull();
|
|
134
|
+
expect(ref.current?.tagName).toBe("INPUT");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("uses custom errorMessage when provided", () => {
|
|
138
|
+
render(
|
|
139
|
+
<DateField
|
|
140
|
+
aria-label="date-input"
|
|
141
|
+
errorMessage="Custom error message"
|
|
142
|
+
data-testid="custom-test-id"
|
|
143
|
+
/>,
|
|
144
|
+
);
|
|
145
|
+
expect(screen.getByText("Custom error message")).toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("DateField validation on blur", () => {
|
|
149
|
+
it("calls onBlur handler with correct event on blur", async () => {
|
|
150
|
+
const handleChange = jest.fn();
|
|
151
|
+
|
|
152
|
+
render(
|
|
153
|
+
<DateField
|
|
154
|
+
aria-label="date-input"
|
|
155
|
+
data-testid="date-field"
|
|
156
|
+
onBlur={handleChange}
|
|
157
|
+
/>,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const input = screen.getByTestId("date-field");
|
|
161
|
+
await userEvent.type(input, "2023-10-15");
|
|
162
|
+
fireEvent.blur(input);
|
|
163
|
+
|
|
164
|
+
expect(handleChange).toHaveBeenCalledTimes(1);
|
|
165
|
+
|
|
166
|
+
expect(handleChange.mock.calls[0][0].target.value).toEqual("2023-10-15");
|
|
167
|
+
expect(screen.queryByText("Invalid date input")).not.toBeInTheDocument();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("validates against min date on blur", async () => {
|
|
171
|
+
render(
|
|
172
|
+
<DateField
|
|
173
|
+
aria-label="date-input"
|
|
174
|
+
data-testid="date-field"
|
|
175
|
+
min="2023-10-10"
|
|
176
|
+
/>,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const input = screen.getByTestId("date-field");
|
|
180
|
+
await userEvent.clear(input);
|
|
181
|
+
await userEvent.type(input, "2023-10-05");
|
|
182
|
+
fireEvent.blur(input);
|
|
183
|
+
|
|
184
|
+
expect(screen.getByText("Invalid date input")).toBeInTheDocument();
|
|
185
|
+
|
|
186
|
+
// Test valid date
|
|
187
|
+
await userEvent.clear(input);
|
|
188
|
+
await userEvent.type(input, "2023-10-15");
|
|
189
|
+
fireEvent.blur(input);
|
|
190
|
+
|
|
191
|
+
expect(screen.queryByText("Invalid date input")).not.toBeInTheDocument();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("validates against max date on blur", async () => {
|
|
195
|
+
render(
|
|
196
|
+
<DateField
|
|
197
|
+
aria-label="date-input"
|
|
198
|
+
data-testid="date-field"
|
|
199
|
+
max="2023-10-20"
|
|
200
|
+
/>,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const input = screen.getByTestId("date-field");
|
|
204
|
+
await userEvent.clear(input);
|
|
205
|
+
await userEvent.type(input, "2023-10-25");
|
|
206
|
+
fireEvent.blur(input);
|
|
207
|
+
|
|
208
|
+
expect(screen.getByText("Invalid date input")).toBeInTheDocument();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("resets validation state when valid input is provided after invalid input", async () => {
|
|
212
|
+
render(
|
|
213
|
+
<DateField
|
|
214
|
+
aria-label="date-input"
|
|
215
|
+
data-testid="date-field"
|
|
216
|
+
min="2023-01-01"
|
|
217
|
+
max="2023-12-31"
|
|
218
|
+
/>,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const input = screen.getByTestId("date-field");
|
|
222
|
+
|
|
223
|
+
// Enter invalid date
|
|
224
|
+
await userEvent.clear(input);
|
|
225
|
+
await userEvent.type(input, "2024-01-15");
|
|
226
|
+
fireEvent.blur(input);
|
|
227
|
+
|
|
228
|
+
expect(screen.getByText("Invalid date input")).toBeInTheDocument();
|
|
229
|
+
|
|
230
|
+
// Enter valid date
|
|
231
|
+
await userEvent.clear(input);
|
|
232
|
+
await userEvent.type(input, "2023-06-15");
|
|
233
|
+
fireEvent.blur(input);
|
|
234
|
+
|
|
235
|
+
expect(screen.queryByText("Invalid date input")).not.toBeInTheDocument();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import classNames from "classnames/dedupe";
|
|
4
|
+
import type { FocusEvent, RefAttributes } from "react";
|
|
5
|
+
import { forwardRef, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { mergeRefs } from "../../utils/mergeRefs";
|
|
7
|
+
import {
|
|
8
|
+
TextField,
|
|
9
|
+
type TextFieldElementType,
|
|
10
|
+
type TextFieldProps,
|
|
11
|
+
type TextFieldRef,
|
|
12
|
+
} from "../TextField";
|
|
13
|
+
import { convertToDateFormat, isValidDate } from "./validation";
|
|
14
|
+
|
|
15
|
+
export interface DateFieldProps
|
|
16
|
+
extends Omit<TextFieldProps, "type">,
|
|
17
|
+
RefAttributes<TextFieldElementType> {
|
|
18
|
+
/** The earliest date allowed for the input. */
|
|
19
|
+
min?: string;
|
|
20
|
+
/** The latest date allowed for the input. */
|
|
21
|
+
max?: string;
|
|
22
|
+
/** Date format to use. */
|
|
23
|
+
format?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type DateFieldRef = TextFieldRef;
|
|
27
|
+
|
|
28
|
+
export const MIN_MAX_ERROR =
|
|
29
|
+
'"min" value should not be greater than "max" value.';
|
|
30
|
+
|
|
31
|
+
export const DateField = forwardRef<HTMLInputElement, DateFieldProps>(
|
|
32
|
+
(props, ref) => {
|
|
33
|
+
const {
|
|
34
|
+
min,
|
|
35
|
+
max,
|
|
36
|
+
format,
|
|
37
|
+
className,
|
|
38
|
+
errorMessage,
|
|
39
|
+
defaultValue,
|
|
40
|
+
value,
|
|
41
|
+
...otherProps
|
|
42
|
+
} = props;
|
|
43
|
+
const [error, setError] = useState<string | undefined>(errorMessage);
|
|
44
|
+
const [isInvalid, setIsInvalid] = useState<boolean | undefined>(undefined);
|
|
45
|
+
const localRef = useRef<TextFieldElementType>(null);
|
|
46
|
+
const classes = classNames("mobius-date-field", className);
|
|
47
|
+
|
|
48
|
+
// If a custom format is provided, convert the min, max,
|
|
49
|
+
// and defaultValue dates to that format
|
|
50
|
+
const formattedMin = min ? convertToDateFormat(min, format) : undefined;
|
|
51
|
+
const formattedMax = max ? convertToDateFormat(max, format) : undefined;
|
|
52
|
+
const formattedDefaultValue = defaultValue
|
|
53
|
+
? convertToDateFormat(defaultValue, format)
|
|
54
|
+
: undefined;
|
|
55
|
+
const formattedValue = value
|
|
56
|
+
? convertToDateFormat(value, format)
|
|
57
|
+
: undefined;
|
|
58
|
+
|
|
59
|
+
const setInvalidState = (error?: string) => {
|
|
60
|
+
setError(error);
|
|
61
|
+
setIsInvalid(true);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const setValidState = () => {
|
|
65
|
+
setError(props.errorMessage);
|
|
66
|
+
setIsInvalid(false);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Validate min and max values
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!isValidDate(min, format)) {
|
|
72
|
+
setInvalidState(`Invalid min date: ${min}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!isValidDate(max, format)) {
|
|
76
|
+
setInvalidState(`Invalid max date: ${max}`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (min && max) {
|
|
81
|
+
const minDate = new Date(min);
|
|
82
|
+
const maxDate = new Date(max);
|
|
83
|
+
if (minDate > maxDate) {
|
|
84
|
+
setInvalidState(MIN_MAX_ERROR);
|
|
85
|
+
} else {
|
|
86
|
+
setValidState();
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
setValidState();
|
|
90
|
+
}
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
}, [min, max, format]);
|
|
93
|
+
|
|
94
|
+
const validate = () => {
|
|
95
|
+
// If 'min' or 'max' values are provided, checkValidity() will
|
|
96
|
+
// validate the date and return a boolean
|
|
97
|
+
const isValidInput = localRef.current?.checkValidity();
|
|
98
|
+
|
|
99
|
+
if (!isValidInput) {
|
|
100
|
+
setInvalidState("Invalid date input");
|
|
101
|
+
} else {
|
|
102
|
+
setValidState();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// User has interacted with the component and navigated away
|
|
107
|
+
const handleBlur = (event: FocusEvent<Element>) => {
|
|
108
|
+
validate();
|
|
109
|
+
otherProps.onBlur?.(event);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<TextField
|
|
114
|
+
ref={mergeRefs([localRef, ref])}
|
|
115
|
+
className={classes}
|
|
116
|
+
type="date"
|
|
117
|
+
min={formattedMin}
|
|
118
|
+
max={formattedMax}
|
|
119
|
+
errorMessage={error}
|
|
120
|
+
isInvalid={isInvalid}
|
|
121
|
+
defaultValue={formattedDefaultValue}
|
|
122
|
+
value={formattedValue}
|
|
123
|
+
onBlur={handleBlur}
|
|
124
|
+
{...otherProps}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
DateField.displayName = "DateField";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./DateField";
|