-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement basic
OnyxDatePicker
component (#2145)
Relates to #1818 Implement a basic date picker comonent.
- Loading branch information
1 parent
1681b11
commit d00c404
Showing
22 changed files
with
308 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"sit-onyx": minor | ||
--- | ||
|
||
feat: implement basic `OnyxDatePicker` component |
Binary file added
BIN
+25.6 KB
...snapshots/components/OnyxDatePicker/DatePicker-date-default--chromium-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+40 KB
.../snapshots/components/OnyxDatePicker/DatePicker-date-default--firefox-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+26.8 KB
...t/snapshots/components/OnyxDatePicker/DatePicker-date-default--webkit-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+27.1 KB
...pshots/components/OnyxDatePicker/DatePicker-date-with-value--chromium-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+40.3 KB
...apshots/components/OnyxDatePicker/DatePicker-date-with-value--firefox-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+33.2 KB
...napshots/components/OnyxDatePicker/DatePicker-date-with-value--webkit-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+27.1 KB
...components/OnyxDatePicker/DatePicker-datetime-local-default--chromium-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+41.4 KB
.../components/OnyxDatePicker/DatePicker-datetime-local-default--firefox-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+27.9 KB
...s/components/OnyxDatePicker/DatePicker-datetime-local-default--webkit-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+30.5 KB
...ponents/OnyxDatePicker/DatePicker-datetime-local-with-value--chromium-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+44.4 KB
...mponents/OnyxDatePicker/DatePicker-datetime-local-with-value--firefox-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+38.9 KB
...omponents/OnyxDatePicker/DatePicker-datetime-local-with-value--webkit-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 66 additions & 0 deletions
66
packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.ct.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"], | ||
}); | ||
}); |
33 changes: 33 additions & 0 deletions
33
packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.stories.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
146
packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}; |