From 1699ae6b263d9bf044075e6b907305087b28a29c Mon Sep 17 00:00:00 2001 From: Joshua Graber Date: Tue, 19 Nov 2024 16:12:27 -0500 Subject: [PATCH 1/6] fix(components): input select corner cases --- .../InputSelect/PdapInputSelect.vue | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) 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; + } + } +); 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..c12eb66 --- /dev/null +++ b/src/components/InputDatePicker/input-date-picker.spec.ts @@ -0,0 +1,344 @@ +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` + ); + }); + + 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` + ); + }); + + 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); + }); + + 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'); + }); + + 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'); + }); + }); + + 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/index.ts b/src/components/index.ts index d97475b..2d8544b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ export { Form } from './Form'; export { FormV2 } from './FormV2'; export { Input } from './Input'; export { InputCheckbox } from './InputCheckbox'; +export { InputDatePicker } from './InputDatePicker'; export { InputPassword } from './InputPassword'; export { InputText } from './InputText'; export { InputSelect } from './InputSelect'; diff --git a/src/demo/pages/FormV2Demo.vue b/src/demo/pages/FormV2Demo.vue index af2188c..991ad50 100644 --- a/src/demo/pages/FormV2Demo.vue +++ b/src/demo/pages/FormV2Demo.vue @@ -37,7 +37,6 @@ + + + + Date: Wed, 20 Nov 2024 10:04:49 -0500 Subject: [PATCH 4/6] chore(docs): auto-update to component docs --- docs/components.md | 1 + 1 file changed, 1 insertion(+) 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) From e885c7b59be8798d7d66526fb76885d5e9ad0352 Mon Sep 17 00:00:00 2001 From: Joshua Graber Date: Wed, 20 Nov 2024 10:17:15 -0500 Subject: [PATCH 5/6] docs(components): add docs for date picker --- src/components/InputDatePicker/README.md | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/components/InputDatePicker/README.md 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 + + + + +... +``` From f56071cff970446278e8f80458df5ff800615bbb Mon Sep 17 00:00:00 2001 From: Joshua Graber Date: Wed, 20 Nov 2024 10:23:24 -0500 Subject: [PATCH 6/6] test(components): add snapshot checks for date picker --- .../input-date-picker.spec.ts.snap | 50 +++++++++++++++++++ .../InputDatePicker/input-date-picker.spec.ts | 5 ++ 2 files changed, 55 insertions(+) create mode 100644 src/components/InputDatePicker/__snapshots__/input-date-picker.spec.ts.snap 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`] = ` +
+ +
+ Custom Error Message +
+ +
+`; + +exports[`PdapInputDatePicker > Rendering > renders label prop when no slot is provided 1`] = ` +
+ + + +
+`; + +exports[`PdapInputDatePicker > Rendering > renders label slot when provided 1`] = ` +
+ + + +
+`; diff --git a/src/components/InputDatePicker/input-date-picker.spec.ts b/src/components/InputDatePicker/input-date-picker.spec.ts index c12eb66..04389cb 100644 --- a/src/components/InputDatePicker/input-date-picker.spec.ts +++ b/src/components/InputDatePicker/input-date-picker.spec.ts @@ -101,6 +101,7 @@ describe('PdapInputDatePicker', () => { expect(label.attributes('id')).toBe( `${defaultProps.name}-${defaultProps.id}-label` ); + expect(wrapper.html()).toMatchSnapshot(); }); it('renders label slot when provided', () => { @@ -134,6 +135,7 @@ describe('PdapInputDatePicker', () => { 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', () => { @@ -158,6 +160,7 @@ describe('PdapInputDatePicker', () => { }); expect(wrapper.find('label').exists()).toBe(false); + expect(wrapper.html()).toMatchSnapshot(); }); it('renders complex label slot content', () => { @@ -192,6 +195,7 @@ describe('PdapInputDatePicker', () => { 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', () => { @@ -230,6 +234,7 @@ describe('PdapInputDatePicker', () => { ); expect(errorElement.exists()).toBe(true); expect(errorElement.text()).toBe('Custom Error Message'); + expect(wrapper.html()).toMatchSnapshot(); }); });