Skip to content

Commit

Permalink
feat: implement basic OnyxDatePicker component (#2145)
Browse files Browse the repository at this point in the history
Relates to #1818

Implement a basic date picker comonent.
  • Loading branch information
larsrickert authored Dec 2, 2024
1 parent 1681b11 commit d00c404
Show file tree
Hide file tree
Showing 22 changed files with 308 additions and 2 deletions.
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;
146 changes: 146 additions & 0 deletions packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<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}`;
};
});
const handleInput = (event: Event) => {
const input = event.target as HTMLInputElement;
const newValue = input.valueAsDate;
emit("update:modelValue", newValue?.toISOString());
};
</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-custom-validity
:value="getNormalizedDate(props.modelValue)"
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"
@input="handleInput"
/>
</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);
};

0 comments on commit d00c404

Please sign in to comment.