@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.
@@ -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 * from "./DateField";
@@ -0,0 +1,2 @@
1
+ export declare const convertToDateFormat: (date: string, format?: string) => string;
2
+ export declare const isValidDate: (date?: string, format?: string) => boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@simplybusiness/mobius",
3
3
  "license": "UNLICENSED",
4
- "version": "5.26.3",
4
+ "version": "5.27.0",
5
5
  "description": "Core library of Mobius react components",
6
6
  "repository": {
7
7
  "type": "git",
@@ -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";