diff --git a/.husky/post-commit b/.husky/post-commit new file mode 100755 index 0000000..6bff3d4 --- /dev/null +++ b/.husky/post-commit @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +[ -n "$CI" ] && exit 0 + +. "$(dirname -- "$0")/_/husky.sh" + +npm run docs \ No newline at end of file diff --git a/.husky/pre-push b/.husky/pre-push index 858630c..115cd65 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -npm run docs && npm run test:ci +npm run test:ci diff --git a/docs/components.md b/docs/components.md index d573de4..3c9303f 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) +- [InputSelect](../src/components/InputSelect//README.md) - [Nav](../src/components/Nav//README.md) - [QuickSearchForm](../src/components/QuickSearchForm//README.md) - [Spinner](../src/components/Spinner//README.md) diff --git a/scripts/update-docs.sh b/scripts/update-docs.sh index c17de7f..e00374f 100644 --- a/scripts/update-docs.sh +++ b/scripts/update-docs.sh @@ -20,11 +20,11 @@ done # create a commit, only if there are changes if git diff --quiet docs/$output_file; then - echo -e "No new component README files detected.\nProceeding with push" + echo -e "No new component README files detected.\nNo new commit will be created." exit 0 else commit_msg="chore(docs): auto-update to component docs" echo "New README files detected, committing updated docs/$output_file file..." - git add docs/$output_file && git commit -m "$commit_msg" --no-verify && echo "Commit finished, proceeding with push" + git add docs/$output_file && git commit -m "$commit_msg" --no-verify && echo "Updated documentation added to the TOC in \`docs/components.md\` and committed." fi diff --git a/src/components/FormV2/__snapshots__/formv2.spec.ts.snap b/src/components/FormV2/__snapshots__/formv2.spec.ts.snap index b04c310..096aa7d 100644 --- a/src/components/FormV2/__snapshots__/formv2.spec.ts.snap +++ b/src/components/FormV2/__snapshots__/formv2.spec.ts.snap @@ -4,16 +4,17 @@ exports[`PdapFormV2 > calls submit event with form values on valid submission 1`
+ -
+ -
+
@@ -23,7 +24,6 @@ exports[`PdapFormV2 > calls submit event with form values on valid submission 1`
-
@@ -37,16 +37,17 @@ exports[`PdapFormV2 > renders default error message when form has errors 1`] = `
Please update this form to correct the errors
+
Value is required
-
+
Value is required
-
+
@@ -56,7 +57,6 @@ exports[`PdapFormV2 > renders default error message when form has errors 1`] = `
-
@@ -76,16 +76,17 @@ exports[`PdapFormV2 > renders error message when errorMessage prop is provided 1
Form Error
+ -
+ -
+
@@ -95,7 +96,6 @@ exports[`PdapFormV2 > renders error message when errorMessage prop is provided 1
-
@@ -109,16 +109,17 @@ exports[`PdapFormV2 > renders the form element 1`] = `
+ -
+ -
+
@@ -128,7 +129,6 @@ exports[`PdapFormV2 > renders the form element 1`] = `
-
diff --git a/src/components/InputCheckbox/PdapInputCheckbox.vue b/src/components/InputCheckbox/PdapInputCheckbox.vue index 76d784f..c47c6a9 100644 --- a/src/components/InputCheckbox/PdapInputCheckbox.vue +++ b/src/components/InputCheckbox/PdapInputCheckbox.vue @@ -3,7 +3,9 @@ class="pdap-input pdap-input-checkbox" :class="{ ['pdap-input-error']: error }" > - +
+ +
{{ error }}
- + + +
+ +
{{ error }}
@@ -25,9 +29,6 @@
- - -
diff --git a/src/components/InputSelect/PdapInputSelect.vue b/src/components/InputSelect/PdapInputSelect.vue new file mode 100644 index 0000000..9640adf --- /dev/null +++ b/src/components/InputSelect/PdapInputSelect.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/src/components/InputSelect/README.md b/src/components/InputSelect/README.md new file mode 100644 index 0000000..02956ce --- /dev/null +++ b/src/components/InputSelect/README.md @@ -0,0 +1,84 @@ +# InputSelect +Accessible, flexible custom select component. + +_Note: Only works with `FormV2`. The `FormV1` schema system is not set up to handle this input._ + +## Props + +| 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 | | +| `placeholder` | no | `string` | placeholder attr | "Select an option" | +| `options` | yes | `Array<{value: string; label: string}>` | options | | + +## 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 + + + + +... +``` diff --git a/src/components/InputSelect/index.ts b/src/components/InputSelect/index.ts new file mode 100644 index 0000000..8472aa3 --- /dev/null +++ b/src/components/InputSelect/index.ts @@ -0,0 +1 @@ +export { default as InputSelect } from './PdapInputSelect.vue'; diff --git a/src/components/InputSelect/input-select.spec.ts b/src/components/InputSelect/input-select.spec.ts new file mode 100644 index 0000000..012de6c --- /dev/null +++ b/src/components/InputSelect/input-select.spec.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import PdapInputSelect from './PdapInputSelect.vue'; +import { nextTick } from 'vue'; +import { provideKey } from '../FormV2/util'; + +const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, +]; + +const defaultProps = { + name: 'testSelect', + options, + id: 'testId', + label: 'Test Label', +}; + +const mockFormProvide = { + setValues: vi.fn(), + values: {}, + v$: { value: {} }, +}; + +const BASE_DEFAULT = { + props: defaultProps, + global: { + provide: { + [provideKey as symbol]: mockFormProvide, + }, + }, +}; + +describe('PdapInputSelect', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly with default props', () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + expect(wrapper.find('label').text()).toBe('Test Label'); + expect(wrapper.find('.selected-value').text()).toBe('Select an option'); + expect(wrapper.findAll('.pdap-custom-select-option').length).toBe(3); + }); + + it('opens options when clicked', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper.find('.pdap-custom-select').trigger('click'); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + expect(wrapper.find('.pdap-custom-select-options').isVisible()).toBe(true); + }); + + it('selects an option when clicked', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper.find('.pdap-custom-select').trigger('click'); + await wrapper.findAll('.pdap-custom-select-option')[1].trigger('click'); + + expect(wrapper.find('.selected-value').text()).toBe('Option 2'); + expect(mockFormProvide.setValues).toHaveBeenCalledWith({ + testSelect: 'option2', + }); + }); + + it('handles keyboard navigation', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(0); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(1); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.selected-value').text()).toBe('Option 2'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Escape' }); + expect(wrapper.find('.pdap-custom-select').classes()).not.toContain('open'); + }); + + it('displays error message when provided', async () => { + const wrapper = mount(PdapInputSelect, { + ...BASE_DEFAULT, + global: { + ...BASE_DEFAULT.global, + provide: { + ...BASE_DEFAULT.global.provide, + [provideKey as symbol]: { + ...mockFormProvide, + v$: { + value: { + testSelect: { + $error: true, + $errors: [{ $message: 'Error message' }], + }, + }, + }, + }, + }, + }, + }); + + await nextTick(); + expect(wrapper.find('.pdap-input-error-message').exists()).toBe(true); + expect(wrapper.find('.pdap-input-error-message').text()).toBe( + 'Error message' + ); + }); + + // it('updates when form values change', async () => { + // const wrapper = mount(PdapInputSelect, { + // ...BASE_DEFAULT, + // props: defaultProps, + // global: { + // ...BASE_DEFAULT.global, + // provide: { + // [provideKey as symbol]: { + // ...mockFormProvide, + // values: { testSelect: 'option3' }, + // }, + // }, + // }, + // }); + + // await wrapper.vm.$forceUpdate(); + // expect(wrapper.find('.selected-value').text()).toBe('Option 3'); + // }); + + it('handles Tab key navigation', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + // const options = wrapper.findAll('.pdap-custom-select-option'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Tab' }); + expect(wrapper.find('.pdap-custom-select').classes()).not.toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + // TODO: figure out why this test isn't working + // await wrapper.find('.pdap-custom-select').trigger('keydown', { key: 'Tab' }); + // expect(options[0].classes()).toContain('selected'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Tab' }); + expect(wrapper.emitted('keydown')).toBeTruthy(); + }); + + it('handles ArrowDown key navigation', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(0); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(1); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(2); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(2); + }); + + it('handles ArrowUp key navigation', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowUp' }); + expect(wrapper.find('.pdap-custom-select').classes()).not.toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(0); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowUp' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(-1); + }); + + it('handles Enter key navigation', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(0); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.selected-value').text()).toBe('Option 1'); + expect(wrapper.find('.pdap-custom-select').classes()).not.toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + }); + + it('handles Escape key navigation', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Escape' }); + expect(wrapper.find('.pdap-custom-select').classes()).not.toContain('open'); + }); +}); diff --git a/src/components/InputSelect/types.ts b/src/components/InputSelect/types.ts new file mode 100644 index 0000000..f286f9c --- /dev/null +++ b/src/components/InputSelect/types.ts @@ -0,0 +1,12 @@ +export interface PdapSelectOption { + value: string; + label: string; +} + +export interface PdapInputSelectProps { + id: string; + label?: string; + name: string; + placeholder?: string; + options: PdapSelectOption[]; +} diff --git a/src/components/InputText/PdapInputText.vue b/src/components/InputText/PdapInputText.vue index e42066f..d8952d9 100644 --- a/src/components/InputText/PdapInputText.vue +++ b/src/components/InputText/PdapInputText.vue @@ -1,6 +1,10 @@ diff --git a/src/demo/pages/FormV2Demo.vue b/src/demo/pages/FormV2Demo.vue index 441733e..0a4ace3 100644 --- a/src/demo/pages/FormV2Demo.vue +++ b/src/demo/pages/FormV2Demo.vue @@ -11,7 +11,7 @@ :id="INPUT_TEXT_NAME" autocomplete="off" :name="INPUT_TEXT_NAME" - :placeholder="PLACEHOLDER" + :placeholder="INPUT_TEXT_PLACEHOLDER" >