diff --git a/.changeset/stupid-bottles-clap.md b/.changeset/stupid-bottles-clap.md
new file mode 100644
index 0000000000..9adbc47866
--- /dev/null
+++ b/.changeset/stupid-bottles-clap.md
@@ -0,0 +1,7 @@
+---
+"sit-onyx": minor
+---
+
+feat(OnyxDatePicker): implement `min` and `max` property
+
+Also fix the bug where the value is always `undefined`, when using `type="datetime-local"`
diff --git a/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.ct.tsx b/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.ct.tsx
index 2a0532a768..c79783ad09 100644
--- a/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.ct.tsx
+++ b/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.ct.tsx
@@ -64,3 +64,45 @@ test("should emit events", async ({ mount, makeAxeBuilder }) => {
updateModelValue: ["2024-11-25T00:00:00.000Z"],
});
});
+
+test("should show min errors", async ({ mount }) => {
+ // ARRANGE
+ const component = await mount(
+ ,
+ );
+
+ await expect(component).toBeVisible();
+
+ // error is only shown after interaction so we need to interact first to see the error
+ const input = component.getByLabel("Label");
+ await input.click();
+ await input.blur();
+
+ await expect(component).toContainText("Too low");
+ await expect(component).toContainText("Value must be greater than or equal to 12/10/2024");
+});
+
+test("should show max errors", async ({ mount }) => {
+ // ARRANGE
+ const component = await mount(
+ ,
+ );
+
+ await expect(component).toBeVisible();
+
+ // error is only shown after interaction so we need to interact first to see the error
+ const input = component.getByLabel("Label");
+ await input.click();
+ await input.blur();
+
+ await expect(component).toContainText("Too high");
+ await expect(component).toContainText("Value must be less than or equal to 12/06/2024");
+});
diff --git a/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.stories.ts b/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.stories.ts
index 3a5437dda3..4bafdf3200 100644
--- a/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.stories.ts
+++ b/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.stories.ts
@@ -2,6 +2,10 @@ import { withNativeEventLogging } from "@sit-onyx/storybook-utils";
import type { Meta, StoryObj } from "@storybook/vue3";
import OnyxDatePicker from "./OnyxDatePicker.vue";
+/**
+ * The DatePicker component can be used to select a date or date + time.
+ * **Note**: For now, the calendar flyout will use the native browser calendar. This might be replaced with a custom implementation in the future.
+ */
const meta: Meta = {
title: "Form Elements/DatePicker",
component: OnyxDatePicker,
@@ -13,13 +17,16 @@ const meta: Meta = {
],
argTypes: {
...withNativeEventLogging(["onInput", "onChange", "onFocusin", "onFocusout"]),
+ modelValue: { control: { type: "text" } },
+ min: { control: { type: "date" } },
+ max: { control: { type: "date" } },
},
};
export default meta;
type Story = StoryObj;
-export const Date = {
+export const Default = {
args: {
label: "Date",
},
@@ -31,3 +38,18 @@ export const Datetime = {
type: "datetime-local",
},
} satisfies Story;
+
+export const MinAndMaxDate = {
+ args: {
+ label: "With min. and max. date",
+ type: "datetime-local",
+ min: getRelativeDate(-3),
+ max: getRelativeDate(3),
+ },
+} satisfies Story;
+
+function getRelativeDate(offsetDays: number) {
+ const date = new Date();
+ date.setDate(date.getDate() + offsetDays);
+ return date;
+}
diff --git a/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue b/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue
index f9889df7b8..7c21126894 100644
--- a/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue
+++ b/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue
@@ -63,11 +63,16 @@ const getNormalizedDate = computed(() => {
};
});
-const handleInput = (event: Event) => {
- const input = event.target as HTMLInputElement;
- const newValue = input.valueAsDate;
- emit("update:modelValue", newValue?.toISOString());
-};
+/**
+ * Current value (with getter and setter) that can be used as "v-model" for the native input.
+ */
+const value = computed({
+ get: () => getNormalizedDate.value(props.modelValue),
+ set: (value) => {
+ const newDate = new Date(value ?? "");
+ emit("update:modelValue", isValidDate(newDate) ? newDate.toISOString() : undefined);
+ },
+});
@@ -94,8 +99,8 @@ const handleInput = (event: Event) => {
{
:disabled="disabled || props.loading"
:aria-label="props.hideLabel ? props.label : undefined"
:title="props.hideLabel ? props.label : undefined"
- @input="handleInput"
+ :min="getNormalizedDate(props.min)"
+ :max="getNormalizedDate(props.max)"
/>
diff --git a/packages/sit-onyx/src/components/OnyxDatePicker/types.ts b/packages/sit-onyx/src/components/OnyxDatePicker/types.ts
index a6c978cf32..b50f5670fd 100644
--- a/packages/sit-onyx/src/components/OnyxDatePicker/types.ts
+++ b/packages/sit-onyx/src/components/OnyxDatePicker/types.ts
@@ -20,6 +20,16 @@ export type OnyxDatePickerProps = Omit<
* Whether the user should be able to select only date or date + time.
*/
type?: "date" | "datetime-local";
+ /**
+ * Min. / earliest selectable date (inclusive).
+ * When using `type="datetime-local"`, the user can still select a invalid time but the datepicker will show an error.
+ */
+ min?: DateValue;
+ /**
+ * Max. / latest selectable date (inclusive).
+ * When using `type="datetime-local"`, the user can still select a invalid time but the datepicker will show an error.
+ */
+ max?: DateValue;
};
/** Data types that are parsable as date via `new Date()`. */
diff --git a/packages/sit-onyx/src/composables/useCustomValidity.spec.ts b/packages/sit-onyx/src/composables/useCustomValidity.spec.ts
index 8ed1acf6ca..2366d3d039 100644
--- a/packages/sit-onyx/src/composables/useCustomValidity.spec.ts
+++ b/packages/sit-onyx/src/composables/useCustomValidity.spec.ts
@@ -1,5 +1,5 @@
-import { describe, expect, test, vi } from "vitest";
-import { nextTick, reactive } from "vue";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { nextTick, reactive, ref } from "vue";
import {
useCustomValidity,
type InputValidationElement,
@@ -11,6 +11,7 @@ const tFunctionMock = vi.fn();
vi.mock("../i18n", () => ({
injectI18n: () => ({
t: { value: tFunctionMock },
+ locale: ref("en-US"),
}),
}));
@@ -29,9 +30,11 @@ const getDefaultValidityState = (): ValidityState => ({
});
describe("useCustomValidity", () => {
- test("should set custom error", async () => {
+ beforeEach(() => {
tFunctionMock.mockReset();
+ });
+ test("should set custom error", async () => {
const initialValidity: ValidityState = {
...getDefaultValidityState(),
customError: true,
@@ -46,6 +49,7 @@ describe("useCustomValidity", () => {
} satisfies InputValidationElement;
const props = reactive({
+ label: "Label",
customError: "Test error",
});
@@ -101,9 +105,8 @@ describe("useCustomValidity", () => {
[cause]: true,
valid: false,
};
- const props = reactive({});
- const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit: (_, __) => {} });
- tFunctionMock.mockReset();
+ const props = reactive({ label: "Label" });
+ const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit: () => ({}) });
tFunctionMock.mockReturnValueOnce("Test");
tFunctionMock.mockReturnValueOnce("This is a test");
const mockInput = {
@@ -120,4 +123,112 @@ describe("useCustomValidity", () => {
expect(tFunctionMock).toBeCalledWith(`${key}.preview`);
expect(tFunctionMock).toBeCalledWith(`${key}.fullError`, expect.any(Object));
});
+
+ test("should format date min errors", async () => {
+ // ARRANGE
+ const initialValidity: ValidityState = {
+ ...getDefaultValidityState(),
+ rangeUnderflow: true,
+ valid: false,
+ };
+
+ const mockInput = {
+ validity: initialValidity,
+ setCustomValidity: vi.fn(),
+ } satisfies InputValidationElement;
+
+ const props = reactive({
+ label: "Label",
+ type: "date",
+ min: new Date(2024, 11, 10, 14, 42),
+ });
+
+ const { vCustomValidity, errorMessages } = useCustomValidity({
+ props,
+ emit: () => ({}),
+ });
+
+ tFunctionMock.mockImplementationOnce(
+ (translationKey, params) => `${translationKey}: ${params.min}`,
+ );
+ tFunctionMock.mockReturnValueOnce("Too low");
+
+ vCustomValidity.mounted(mockInput);
+ await nextTick(); // wait for watchers to be called
+
+ // ASSERT
+ expect(errorMessages.value).toStrictEqual({
+ shortMessage: "Too low",
+ longMessage: "validations.rangeUnderflow.fullError: 12/10/2024",
+ });
+
+ // ACT
+ tFunctionMock.mockImplementationOnce(
+ (translationKey, params) => `${translationKey}: ${params.min}`,
+ );
+ tFunctionMock.mockReturnValueOnce("Too low");
+
+ props.type = "datetime-local";
+ await nextTick();
+
+ // ASSERT
+ expect(errorMessages.value).toStrictEqual({
+ shortMessage: "Too low",
+ longMessage: "validations.rangeUnderflow.fullError: 12/10/2024, 02:42 PM",
+ });
+ });
+
+ test("should format date max errors", async () => {
+ // ARRANGE
+ const initialValidity: ValidityState = {
+ ...getDefaultValidityState(),
+ rangeOverflow: true,
+ valid: false,
+ };
+
+ const mockInput = {
+ validity: initialValidity,
+ setCustomValidity: vi.fn(),
+ } satisfies InputValidationElement;
+
+ const props = reactive({
+ label: "Label",
+ type: "date",
+ max: new Date(2024, 11, 10, 14, 42),
+ });
+
+ const { vCustomValidity, errorMessages } = useCustomValidity({
+ props,
+ emit: () => ({}),
+ });
+
+ tFunctionMock.mockImplementationOnce(
+ (translationKey, params) => `${translationKey}: ${params.max}`,
+ );
+ tFunctionMock.mockReturnValueOnce("Too high");
+
+ vCustomValidity.mounted(mockInput);
+ await nextTick(); // wait for watchers to be called
+
+ // ASSERT
+ expect(errorMessages.value).toStrictEqual({
+ shortMessage: "Too high",
+ longMessage: "validations.rangeOverflow.fullError: 12/10/2024",
+ });
+
+ // ACT
+ tFunctionMock.mockImplementationOnce(
+ (translationKey, params) => `${translationKey}: ${params.max}`,
+ );
+ tFunctionMock.mockReturnValueOnce("Too high");
+
+ props.type = "datetime-local";
+ await nextTick();
+
+ // ASSERT
+ expect(errorMessages.value).toStrictEqual({
+ shortMessage: "Too high",
+ longMessage: "validations.rangeOverflow.fullError: 12/10/2024, 02:42 PM",
+ });
+ });
});
diff --git a/packages/sit-onyx/src/composables/useCustomValidity.ts b/packages/sit-onyx/src/composables/useCustomValidity.ts
index 7184863661..01309d410f 100644
--- a/packages/sit-onyx/src/composables/useCustomValidity.ts
+++ b/packages/sit-onyx/src/composables/useCustomValidity.ts
@@ -1,9 +1,10 @@
import { computed, ref, watch, watchEffect, type Directive } from "vue";
-import type { OnyxDatePickerProps } from "../components/OnyxDatePicker/types";
+import type { DateValue, OnyxDatePickerProps } from "../components/OnyxDatePicker/types";
import type { InputType } from "../components/OnyxInput/types";
import { injectI18n } from "../i18n";
import enUS from "../i18n/locales/en-US.json";
import type { BaseSelectOption } from "../types";
+import { isValidDate } from "../utils/date";
import { areObjectsFlatEqual } from "../utils/objects";
import { getFirstInvalidType, transformValidityStateToObject } from "../utils/validity";
@@ -25,8 +26,8 @@ export type UseCustomValidityOptions = {
type?: InputType | OnyxDatePickerProps["type"];
maxlength?: number;
minlength?: number;
- min?: number;
- max?: number;
+ min?: DateValue;
+ max?: DateValue;
precision?: number;
} & Pick;
/**
@@ -115,7 +116,7 @@ export const getFormMessageText = (customError?: CustomMessageType): string | un
* ```
*/
export const useCustomValidity = (options: UseCustomValidityOptions) => {
- const { t } = injectI18n();
+ const { t, locale } = injectI18n();
const validityState = ref>();
const isDirty = ref(false);
@@ -202,8 +203,8 @@ export const useCustomValidity = (options: UseCustomValidityOptions) => {
n: options.props.modelValue?.toString().length ?? 0,
minLength: options.props.minlength,
maxLength: options.props.maxlength,
- min: options.props.min,
- max: options.props.max,
+ min: formatMinMax(locale.value, options.props.type, options.props.min),
+ max: formatMinMax(locale.value, options.props.type, options.props.max),
step: options.props.precision,
};
@@ -224,3 +225,23 @@ export const useCustomValidity = (options: UseCustomValidityOptions) => {
errorMessages,
};
};
+
+const formatMinMax = (
+ locale: string,
+ type: UseCustomValidityOptions["props"]["type"],
+ value?: DateValue,
+): string | undefined => {
+ if (!type || !["date", "datetime-local"].includes(type)) return value?.toString();
+
+ const date = value != undefined ? new Date(value) : undefined;
+ if (!isValidDate(date)) return value?.toString();
+
+ const format: Intl.DateTimeFormatOptions = {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ ...(type === "datetime-local" ? { hour: "2-digit", minute: "2-digit" } : undefined),
+ };
+
+ return date.toLocaleString(locale, format);
+};