Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement basic OnyxDatePicker component #2145

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tender-peas-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sit-onyx": minor
---

feat: implement basic `OnyxDatePicker` component
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { DENSITIES } from "../../composables/density";
import { expect, test } from "../../playwright/a11y";
import { executeMatrixScreenshotTest } from "../../playwright/screenshots";
import OnyxDatePicker from "./OnyxDatePicker.vue";

test.describe("Screenshot tests", () => {
for (const type of ["date", "datetime-local"] as const) {
for (const state of ["default", "with value"] as const) {
executeMatrixScreenshotTest({
name: `DatePicker (${type}, ${state})`,
columns: DENSITIES,
rows: ["default", "hover", "focus"],
component: (column) => {
return (
<OnyxDatePicker
label="Test label"
density={column}
modelValue={state === "with value" ? new Date(2024, 10, 25, 14, 30) : undefined}
style="width: 16rem;"
type={type}
/>
);
},
beforeScreenshot: async (component, page, column, row) => {
const datepicker = component.getByLabel("Test label");
if (row === "hover") await datepicker.hover();
if (row === "focus") await datepicker.focus();
},
});
}
}
});

test("should emit events", async ({ mount, makeAxeBuilder }) => {
const events = {
updateModelValue: [] as (string | undefined)[],
};

// ARRANGE
const component = await mount(
<OnyxDatePicker
label="Label"
onUpdate:modelValue={(value) => events.updateModelValue.push(value)}
/>,
);

// should not emit initial events
expect(events).toMatchObject({ updateModelValue: [] });

// ACT
const accessibilityScanResults = await makeAxeBuilder().analyze();

// ASSERT
expect(accessibilityScanResults.violations).toEqual([]);

const inputElement = component.getByLabel("Label");

// ACT
await inputElement.fill("2024-11-25");

// ASSERT
await expect(inputElement).toHaveValue("2024-11-25");
expect(events).toMatchObject({
updateModelValue: ["2024-11-25T00:00:00.000Z"],
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { withNativeEventLogging } from "@sit-onyx/storybook-utils";
import type { Meta, StoryObj } from "@storybook/vue3";
import OnyxDatePicker from "./OnyxDatePicker.vue";

const meta: Meta<typeof OnyxDatePicker> = {
title: "Form Elements/DatePicker",
component: OnyxDatePicker,
decorators: [
(story) => ({
components: { story },
template: `<div style="width: 16rem;"> <story /> </div>`,
}),
],
argTypes: {
...withNativeEventLogging(["onInput", "onChange", "onFocusin", "onFocusout"]),
},
};

export default meta;
type Story = StoryObj<typeof OnyxDatePicker>;

export const Date = {
args: {
label: "Date",
},
} satisfies Story;

export const Datetime = {
args: {
label: "Date + time",
type: "datetime-local",
},
} satisfies Story;
150 changes: 150 additions & 0 deletions packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<script lang="ts" setup>
import { computed } from "vue";
import { useDensity } from "../../composables/density";
import { getFormMessages, useCustomValidity } from "../../composables/useCustomValidity";
import { useErrorClass } from "../../composables/useErrorClass";
import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState";
import { isValidDate } from "../../utils/date";
import { FORM_INJECTED_SYMBOL, useFormContext } from "../OnyxForm/OnyxForm.core";
import OnyxFormElement from "../OnyxFormElement/OnyxFormElement.vue";
import OnyxLoadingIndicator from "../OnyxLoadingIndicator/OnyxLoadingIndicator.vue";
import OnyxSkeleton from "../OnyxSkeleton/OnyxSkeleton.vue";
import type { DateValue, OnyxDatePickerProps } from "./types";

const props = withDefaults(defineProps<OnyxDatePickerProps>(), {
type: "date",
required: false,
readonly: false,
loading: false,
skeleton: SKELETON_INJECTED_SYMBOL,
disabled: FORM_INJECTED_SYMBOL,
showError: FORM_INJECTED_SYMBOL,
});

const emit = defineEmits<{
/**
* Emitted when the current value changes. Will be a ISO timestamp created by `new Date().toISOString()`.
*/
"update:modelValue": [value?: string];
/**
* Emitted when the validity state of the input changes.
*/
validityChange: [validity: ValidityState];
}>();

const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit });
const successMessages = computed(() => getFormMessages(props.success));
const messages = computed(() => getFormMessages(props.message));
const { densityClass } = useDensity(props);
const { disabled, showError } = useFormContext(props);
const skeleton = useSkeletonContext(props);
const errorClass = useErrorClass(showError);

/**
* Gets the normalized date based on the input type that can be passed to the native HTML `<input />`.
* Will be checked to be a valid date.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Date_and_time_formats#date_strings
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Date_and_time_formats#local_date_and_time_strings
*/
const getNormalizedDate = computed(() => {
return (value?: DateValue) => {
const date = value != undefined ? new Date(value) : undefined;
if (!isValidDate(date)) return;

const dateString = date.toISOString().split("T")[0];
if (props.type === "date") return dateString;

// for datetime type, the hour must be in the users local timezone so just returning the string returned by `toISOString()` will be invalid
// since the timezone offset is missing then
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${dateString}T${hours}:${minutes}`;
};
});

/**
* 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);
},
});
</script>

<template>
<div v-if="skeleton" :class="['onyx-datepicker-skeleton', densityClass]">
<OnyxSkeleton v-if="!props.hideLabel" class="onyx-datepicker-skeleton__label" />
<OnyxSkeleton class="onyx-datepicker-skeleton__input" />
</div>

<div v-else :class="['onyx-datepicker', densityClass, errorClass]">
<OnyxFormElement
v-bind="props"
:error-messages="errorMessages"
:success-messages="successMessages"
:message="messages"
>
<template #default="{ id: inputId }">
<div class="onyx-datepicker__wrapper">
<OnyxLoadingIndicator
v-if="props.loading"
class="onyx-datepicker__loading"
type="circle"
/>
<!-- key is needed to keep current value when switching between date and datetime type -->
<input
:id="inputId"
:key="props.type"
v-model="value"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a particular reason why we don't use valueAsDate? Then we don't need to do the conversion magic ourselves 😅

v-custom-validity
class="onyx-datepicker__native"
:class="{ 'onyx-datepicker__native--success': successMessages }"
:type="props.type"
:required="props.required"
:autofocus="props.autofocus"
:name="props.name"
:readonly="props.readonly"
:disabled="disabled || props.loading"
:aria-label="props.hideLabel ? props.label : undefined"
:title="props.hideLabel ? props.label : undefined"
/>
</div>
</template>
</OnyxFormElement>
</div>
</template>

<style lang="scss">
@use "../../styles/mixins/layers.scss";
@use "../../styles/mixins/input.scss";

.onyx-datepicker,
.onyx-datepicker-skeleton {
--onyx-datepicker-padding-vertical: var(--onyx-density-xs);
}

.onyx-datepicker-skeleton {
@include input.define-skeleton-styles(
$height: calc(1lh + 2 * var(--onyx-datepicker-padding-vertical))
);
}

.onyx-datepicker {
@include layers.component() {
@include input.define-shared-styles(
$base-selector: ".onyx-datepicker",
$vertical-padding: var(--onyx-datepicker-padding-vertical)
);

&__native {
&::-webkit-calendar-picker-indicator {
cursor: pointer;
}
}
}
}
</style>
26 changes: 26 additions & 0 deletions packages/sit-onyx/src/components/OnyxDatePicker/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { OnyxInputProps } from "../OnyxInput/types";

export type OnyxDatePickerProps = Omit<
OnyxInputProps,
| "type"
| "modelValue"
| "autocapitalize"
| "maxlength"
| "minlength"
| "pattern"
| "withCounter"
| "placeholder"
| "autocomplete"
> & {
/**
* Current date value. Supports all data types that are parsable by `new Date()`.
*/
modelValue?: DateValue;
/**
* Whether the user should be able to select only date or date + time.
*/
type?: "date" | "datetime-local";
};

/** Data types that are parsable as date via `new Date()`. */
export type DateValue = ConstructorParameters<typeof Date>[0];
1 change: 0 additions & 1 deletion packages/sit-onyx/src/components/OnyxInput/OnyxInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ const errorClass = useErrorClass(showError);
:aria-label="props.hideLabel ? props.label : undefined"
:title="props.hideLabel ? props.label : undefined"
/>
<!-- eslint-enable vuejs-accessibility/no-autofocus -->
</div>
</template>
</OnyxFormElement>
Expand Down
3 changes: 2 additions & 1 deletion packages/sit-onyx/src/composables/useCustomValidity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { computed, ref, watch, watchEffect, type Directive } from "vue";
import type { OnyxDatePickerProps } from "../components/OnyxDatePicker/types";
import type { InputType } from "../components/OnyxInput/types";
import { injectI18n } from "../i18n";
import enUS from "../i18n/locales/en-US.json";
Expand All @@ -21,7 +22,7 @@ export type UseCustomValidityOptions = {
*/
props: CustomValidityProp & {
modelValue?: unknown;
type?: InputType;
type?: InputType | OnyxDatePickerProps["type"];
maxlength?: number;
minlength?: number;
min?: number;
Expand Down
3 changes: 3 additions & 0 deletions packages/sit-onyx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export * from "./components/OnyxDataGrid/types";

export * as DataGridFeatures from "./components/OnyxDataGrid/features/all";

export { default as OnyxDatePicker } from "./components/OnyxDatePicker/OnyxDatePicker.vue";
export * from "./components/OnyxDatePicker/types";

export { default as OnyxDialog } from "./components/OnyxDialog/OnyxDialog.vue";
export * from "./components/OnyxDialog/types";

Expand Down
17 changes: 17 additions & 0 deletions packages/sit-onyx/src/utils/date.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import { isValidDate } from "./date";

describe("date", () => {
test.each([
{ input: "", isValid: false },
{ input: 0, isValid: false },
{ input: false, isValid: false },
{ input: undefined, isValid: false },
{ input: null, isValid: false },
{ input: "not-a-date", isValid: false },
{ input: new Date("not-a-date"), isValid: false },
{ input: new Date(), isValid: true },
])("should determine correctly if $input is a valid date", ({ input, isValid }) => {
expect(isValidDate(input)).toBe(isValid);
});
});
10 changes: 10 additions & 0 deletions packages/sit-onyx/src/utils/date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Checks whether the given value is a valid `Date` object.
*
* @example isValidDate(new Date()) // true
* @example isValidDate("not-a-date") // false
*/
export const isValidDate = (date: unknown): date is Date => {
// isNaN supports Date objects so the type cast here is safe
return date instanceof Date && !isNaN(date as unknown as number);
};
Loading