From 835b11f0a531aa0abb56ec780535a1638b2acf0b Mon Sep 17 00:00:00 2001
From: Joshua Graber <68428039+joshuagraber@users.noreply.github.com>
Date: Wed, 20 Nov 2024 10:29:08 -0500
Subject: [PATCH] feat: date picker (#124)
---
docs/components.md | 1 +
package-lock.json | 50 +++
package.json | 2 +
.../InputDatePicker/PdapInputDatePicker.vue | 138 +++++++
src/components/InputDatePicker/README.md | 53 +++
.../input-date-picker.spec.ts.snap | 50 +++
src/components/InputDatePicker/index.ts | 1 +
.../InputDatePicker/input-date-picker.spec.ts | 349 ++++++++++++++++++
src/components/InputDatePicker/types.ts | 7 +
.../InputSelect/PdapInputSelect.vue | 24 +-
src/components/index.ts | 1 +
src/demo/pages/FormV2Demo.vue | 22 +-
src/index.ts | 1 +
src/styles/components.css | 14 +-
stylelint.config.mjs | 1 +
15 files changed, 700 insertions(+), 14 deletions(-)
create mode 100644 src/components/InputDatePicker/PdapInputDatePicker.vue
create mode 100644 src/components/InputDatePicker/README.md
create mode 100644 src/components/InputDatePicker/__snapshots__/input-date-picker.spec.ts.snap
create mode 100644 src/components/InputDatePicker/index.ts
create mode 100644 src/components/InputDatePicker/input-date-picker.spec.ts
create mode 100644 src/components/InputDatePicker/types.ts
diff --git a/docs/components.md b/docs/components.md
index 3c9303f..b3755d4 100644
--- a/docs/components.md
+++ b/docs/components.md
@@ -9,6 +9,7 @@
- [Form](../src/components/Form//README.md)
- [Header](../src/components/Header//README.md)
- [Input](../src/components/Input//README.md)
+- [InputDatePicker](../src/components/InputDatePicker//README.md)
- [InputSelect](../src/components/InputSelect//README.md)
- [Nav](../src/components/Nav//README.md)
- [QuickSearchForm](../src/components/QuickSearchForm//README.md)
diff --git a/package-lock.json b/package-lock.json
index aab6614..044c247 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"@fortawesome/vue-fontawesome": "^3.0.8",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
+ "@vuepic/vue-datepicker": "^10.0.0",
"fs-extra": "^11.1.1",
"happy-dom": "^6.0.4",
"minimist": "^1.2.8",
@@ -32,6 +33,7 @@
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/release-notes-generator": "^12.1.0",
+ "@types/lodash": "^4.17.13",
"@types/node": "^20.8.9",
"@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^6.9.1",
@@ -2689,6 +2691,12 @@
"integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==",
"dev": true
},
+ "node_modules/@types/lodash": {
+ "version": "4.17.13",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz",
+ "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==",
+ "dev": true
+ },
"node_modules/@types/minimist": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.4.tgz",
@@ -3387,6 +3395,20 @@
}
}
},
+ "node_modules/@vuepic/vue-datepicker": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-10.0.0.tgz",
+ "integrity": "sha512-ujlk3ahftVQpyCJ8hq7TmOOHrf/XFJI1ZcAh/FRB5Ci62Vq5HmHf6xux5KVi5SPUFRTJY78m+uDhYy1M+8RZ9w==",
+ "dependencies": {
+ "date-fns": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ },
+ "peerDependencies": {
+ "vue": ">=3.2.0"
+ }
+ },
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -5062,6 +5084,15 @@
"node": ">=8"
}
},
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@@ -19113,6 +19144,12 @@
"integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==",
"dev": true
},
+ "@types/lodash": {
+ "version": "4.17.13",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz",
+ "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==",
+ "dev": true
+ },
"@types/minimist": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.4.tgz",
@@ -19612,6 +19649,14 @@
"vue-demi": "^0.13.11"
}
},
+ "@vuepic/vue-datepicker": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-10.0.0.tgz",
+ "integrity": "sha512-ujlk3ahftVQpyCJ8hq7TmOOHrf/XFJI1ZcAh/FRB5Ci62Vq5HmHf6xux5KVi5SPUFRTJY78m+uDhYy1M+8RZ9w==",
+ "requires": {
+ "date-fns": "^4.1.0"
+ }
+ },
"abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -20800,6 +20845,11 @@
"integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==",
"dev": true
},
+ "date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
+ },
"de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
diff --git a/package.json b/package.json
index 25b427a..de0871b 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/release-notes-generator": "^12.1.0",
+ "@types/lodash": "^4.17.13",
"@types/node": "^20.8.9",
"@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^6.9.1",
@@ -92,6 +93,7 @@
"@fortawesome/vue-fontawesome": "^3.0.8",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
+ "@vuepic/vue-datepicker": "^10.0.0",
"fs-extra": "^11.1.1",
"happy-dom": "^6.0.4",
"minimist": "^1.2.8",
diff --git a/src/components/InputDatePicker/PdapInputDatePicker.vue b/src/components/InputDatePicker/PdapInputDatePicker.vue
new file mode 100644
index 0000000..0521bf5
--- /dev/null
+++ b/src/components/InputDatePicker/PdapInputDatePicker.vue
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
diff --git a/src/components/InputDatePicker/README.md b/src/components/InputDatePicker/README.md
new file mode 100644
index 0000000..37e4792
--- /dev/null
+++ b/src/components/InputDatePicker/README.md
@@ -0,0 +1,53 @@
+# InputSelect
+Date picker component. Uses Vue3 Date Picker library under the hood.
+
+## Props - required
+
+| name | required? | types | description | default |
+| ------- | ----------------------------- | -------- | ------------- | ------- |
+| `id` | yes | `string` | id attr | |
+| `label` | yes, if label slot not passed | `string` | label content | |
+| `name` | yes | `string` | name attr | |
+
+## Props - Vue3 Date Picker
+The props interface extends the underlying component interface, so [all props available on the Vue 3 Date Picker component](https://vue3datepicker.com/props/modes/) are available to be passed.
+
+## Slots
+
+| name | required? | types | description | default |
+| ------- | ----------------------------- | --------- | ------------------------------------ | ------- |
+| `error` | no* | `Element` | slot content to be rendered as error | |
+| `label` | yes, if label prop not passed | `Element` | slot content to be rendered as label | |
+
+* Note: The error message is determined by Vuelidate via our form validation schema. If the error UI needs to be more complicated than a string that can be passed with the schema, pass an `\#error` slot and it will override the string.
+
+## Example
+
+```vue
+
+ onSubmit({ values })"
+ @change="(values, event) => onChange({ values, event })"
+ >
+
+
+
+ When will you next consume ice cream?
+
+
+
+
+
+
+
+...
+```
diff --git a/src/components/InputDatePicker/__snapshots__/input-date-picker.spec.ts.snap b/src/components/InputDatePicker/__snapshots__/input-date-picker.spec.ts.snap
new file mode 100644
index 0000000..6e5daee
--- /dev/null
+++ b/src/components/InputDatePicker/__snapshots__/input-date-picker.spec.ts.snap
@@ -0,0 +1,50 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`PdapInputDatePicker > Rendering > does not render label when neither prop nor slot is provided 1`] = `
+
+
+
+
+
+`;
+
+exports[`PdapInputDatePicker > Rendering > renders complex label slot content 1`] = `
+
+`;
+
+exports[`PdapInputDatePicker > Rendering > renders error slot when slot is provided 1`] = `
+
+`;
+
+exports[`PdapInputDatePicker > Rendering > renders label prop when no slot is provided 1`] = `
+
+ Label from prop
+
+
+
+`;
+
+exports[`PdapInputDatePicker > Rendering > renders label slot when provided 1`] = `
+
+
+ Custom Label Content
+
+
+
+
+`;
diff --git a/src/components/InputDatePicker/index.ts b/src/components/InputDatePicker/index.ts
new file mode 100644
index 0000000..576327b
--- /dev/null
+++ b/src/components/InputDatePicker/index.ts
@@ -0,0 +1 @@
+export { default as InputDatePicker } from './PdapInputDatePicker.vue';
diff --git a/src/components/InputDatePicker/input-date-picker.spec.ts b/src/components/InputDatePicker/input-date-picker.spec.ts
new file mode 100644
index 0000000..04389cb
--- /dev/null
+++ b/src/components/InputDatePicker/input-date-picker.spec.ts
@@ -0,0 +1,349 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { mount, VueWrapper } from '@vue/test-utils';
+import PdapInputDatePicker from './PdapInputDatePicker.vue';
+import { provideKey } from '../FormV2/util';
+import VueDatePicker from '@vuepic/vue-datepicker';
+import { ref } from 'vue';
+
+describe('PdapInputDatePicker', () => {
+ let wrapper: VueWrapper;
+ const mockSetValues = vi.fn();
+ const mockValues = ref({});
+ const mockV$ = ref({
+ testName: {
+ $error: false,
+ $errors: [],
+ },
+ });
+
+ const defaultProps = {
+ name: 'testName',
+ id: 'test-id',
+ label: 'Test Label',
+ };
+
+ const removeEventListener = vi.fn();
+ // Mock window.matchMedia
+ const mockMatchMedia = vi.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener,
+ dispatchEvent: vi.fn(),
+ }));
+
+ beforeEach(() => {
+ global.MediaQueryListEvent = vi
+ .fn()
+ .mockImplementation((type, eventInitDict) => ({
+ type,
+ matches: eventInitDict.matches,
+ media: '',
+ target: {
+ matches: eventInitDict.matches,
+ },
+ }));
+
+ window.matchMedia = mockMatchMedia;
+ wrapper = mount(PdapInputDatePicker, {
+ props: defaultProps,
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ describe('Rendering', () => {
+ it('renders correctly with default props', () => {
+ expect(wrapper.exists()).toBe(true);
+ expect(wrapper.find('label').text()).toBe('Test Label');
+ expect(wrapper.findComponent(VueDatePicker).exists()).toBe(true);
+ });
+
+ it('renders label prop when no slot is provided', () => {
+ wrapper = mount(PdapInputDatePicker, {
+ props: {
+ ...defaultProps,
+ label: 'Label from prop',
+ },
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+
+ const label = wrapper.find(`label[for="${defaultProps.id}"]`);
+ expect(label.exists()).toBe(true);
+ expect(label.text()).toBe('Label from prop');
+ expect(label.attributes('id')).toBe(
+ `${defaultProps.name}-${defaultProps.id}-label`
+ );
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('renders label slot when provided', () => {
+ wrapper = mount(PdapInputDatePicker, {
+ props: {
+ ...defaultProps,
+ label: 'Label from prop', // This should be ignored when slot is present
+ },
+ slots: {
+ label: 'Custom Label Content ',
+ },
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+
+ const label = wrapper.find(`label[for="${defaultProps.id}"]`);
+ expect(label.exists()).toBe(true);
+ expect(label.find('.custom-label').exists()).toBe(true);
+ expect(label.text()).toBe('Custom Label Content');
+ expect(label.text()).not.toBe('Label from prop');
+ expect(label.attributes('id')).toBe(
+ `${defaultProps.name}-${defaultProps.id}-label`
+ );
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('does not render label when neither prop nor slot is provided', () => {
+ wrapper = mount(PdapInputDatePicker, {
+ props: {
+ name: 'testName',
+ id: 'test-id',
+ // label prop intentionally omitted
+ },
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+
+ expect(wrapper.find('label').exists()).toBe(false);
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('renders complex label slot content', () => {
+ wrapper = mount(PdapInputDatePicker, {
+ props: {
+ ...defaultProps,
+ },
+ slots: {
+ label: `
+
+ Complex Label
+ * Required field
+
+ `,
+ },
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+
+ const label = wrapper.find(`label[for="${defaultProps.id}"]`);
+ expect(label.exists()).toBe(true);
+ expect(label.find('.complex-label').exists()).toBe(true);
+ expect(label.find('.label-title').text()).toBe('Complex Label');
+ expect(label.find('.label-hint').text()).toBe('* Required field');
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('renders error slot when slot is provided', () => {
+ mockV$.value = {
+ ...mockV$.value,
+ testName: {
+ $error: true,
+ // @ts-expect-error
+ $errors: [{ $message: 'Error Message' }],
+ },
+ };
+
+ wrapper = mount(PdapInputDatePicker, {
+ props: {
+ ...defaultProps,
+ },
+ slots: {
+ error: 'Custom Error Message ',
+ },
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+
+ const errorElement = wrapper.find(
+ '.pdap-input-error-message .custom-error'
+ );
+ expect(errorElement.exists()).toBe(true);
+ expect(errorElement.text()).toBe('Custom Error Message');
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+ });
+
+ describe('Form Integration', () => {
+ it('calls setValues when date changes', async () => {
+ const datePicker = wrapper.findComponent(VueDatePicker);
+ const newDate = new Date('2024-01-01');
+
+ await datePicker.vm.$emit('update:modelValue', newDate);
+
+ expect(mockSetValues).toHaveBeenCalledWith({
+ [defaultProps.name]: newDate,
+ });
+ });
+
+ it('updates date when form values change externally', async () => {
+ const newDate = new Date('2024-01-01');
+ mockValues.value = {
+ [defaultProps.name]: newDate,
+ };
+
+ await wrapper.vm.$nextTick();
+
+ // @ts-expect-error
+ expect(wrapper.vm.date).toEqual(newDate);
+ });
+
+ it('clears date when form value is removed', async () => {
+ mockValues.value = {
+ [defaultProps.name]: undefined,
+ };
+
+ await wrapper.vm.$nextTick();
+
+ // @ts-expect-error
+ expect(wrapper.vm.date).toBeUndefined();
+ });
+ });
+
+ describe('Dark Mode', () => {
+ it('initializes with system dark mode preference', () => {
+ const darkModeMatchMedia = vi.fn().mockImplementation(() => ({
+ matches: true,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }));
+ window.matchMedia = darkModeMatchMedia;
+
+ wrapper = mount(PdapInputDatePicker, {
+ props: defaultProps,
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+
+ // @ts-expect-error
+ expect(wrapper.vm.darkModePreference).toBe(true);
+ });
+
+ it('updates dark mode preference when system preference changes', async () => {
+ const mockEvent = {
+ matches: true,
+ type: 'change',
+ } as MediaQueryListEvent;
+
+ // @ts-expect-error
+ wrapper.vm.updateColorMode(mockEvent);
+ await wrapper.vm.$nextTick();
+
+ // @ts-expect-error
+ expect(wrapper.vm.darkModePreference).toBe(true);
+ });
+ });
+
+ describe('Validation', () => {
+ it('shows validation error from v$', async () => {
+ mockV$.value = {
+ testName: {
+ $error: true,
+ // @ts-expect-error
+ $errors: [{ $message: 'Validation error' }],
+ },
+ };
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('.pdap-input-error-message').text()).toBe(
+ 'Validation error'
+ );
+ });
+ });
+
+ describe('Cleanup', () => {
+ it('removes event listener on unmount', async () => {
+ wrapper.unmount();
+
+ expect(removeEventListener).toHaveBeenCalledWith(
+ 'change',
+ expect.any(Function)
+ );
+ });
+ });
+});
diff --git a/src/components/InputDatePicker/types.ts b/src/components/InputDatePicker/types.ts
new file mode 100644
index 0000000..83bfd57
--- /dev/null
+++ b/src/components/InputDatePicker/types.ts
@@ -0,0 +1,7 @@
+import { VueDatePickerProps } from '@vuepic/vue-datepicker';
+
+export interface PdapDatePickerProps extends VueDatePickerProps {
+ id: string;
+ label?: string;
+ name: string;
+}
diff --git a/src/components/InputSelect/PdapInputSelect.vue b/src/components/InputSelect/PdapInputSelect.vue
index 2d6ffce..12d046d 100644
--- a/src/components/InputSelect/PdapInputSelect.vue
+++ b/src/components/InputSelect/PdapInputSelect.vue
@@ -199,9 +199,7 @@ function handleKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
- if (focusedOptionIndex.value >= filteredOptions.value.length - 1) {
- focusedOptionIndex.value = 0;
- } else focusedOptionIndex.value = focusedOptionIndex.value + 1;
+ focusedOptionIndex.value = focusedOptionIndex.value + 1;
break;
case 'ArrowUp':
event.preventDefault();
@@ -244,9 +242,15 @@ watch(
() => focusedOptionIndex.value,
// When the index to focus changes (tracking this state because we need it for other things), focus that input
(nextIndexToFocus) => {
- if (typeof nextIndexToFocus === 'number' && nextIndexToFocus >= 0) {
+ if (typeof nextIndexToFocus === 'number' && nextIndexToFocus < 0) {
+ return;
+ } else if (
+ typeof nextIndexToFocus === 'number' &&
+ nextIndexToFocus >= 0 &&
+ nextIndexToFocus < filteredOptions.value.length
+ ) {
focusOption(nextIndexToFocus);
- }
+ } else focusedOptionIndex.value = 0;
}
);
@@ -291,6 +295,16 @@ watch(
selectedOption.value;
}
);
+
+watch(
+ () => searchText.value,
+ (newValue) => {
+ if (newValue.length <= 1) {
+ selectedOption.value = null;
+ focusedOptionIndex.value = -1;
+ }
+ }
+);