From ad35c5718bd1dc42185b5426732ca2081245744e Mon Sep 17 00:00:00 2001 From: Joshua Graber <68428039+joshuagraber@users.noreply.github.com> Date: Thu, 10 Oct 2024 21:33:30 -0400 Subject: [PATCH] feat(components): create SelectInput (#110) --- .husky/post-commit | 6 + .husky/pre-push | 2 +- docs/components.md | 1 + scripts/update-docs.sh | 4 +- .../FormV2/__snapshots__/formv2.spec.ts.snap | 24 +- .../InputCheckbox/PdapInputCheckbox.vue | 4 +- .../InputPassword/PdapInputPassword.vue | 9 +- .../InputSelect/PdapInputSelect.vue | 281 ++++++++++++++++++ src/components/InputSelect/README.md | 84 ++++++ src/components/InputSelect/index.ts | 1 + .../InputSelect/input-select.spec.ts | 275 +++++++++++++++++ src/components/InputSelect/types.ts | 12 + src/components/InputText/PdapInputText.vue | 9 +- src/demo/pages/FormV2Demo.vue | 61 +++- src/styles/components.css | 3 +- 15 files changed, 749 insertions(+), 27 deletions(-) create mode 100755 .husky/post-commit create mode 100644 src/components/InputSelect/PdapInputSelect.vue create mode 100644 src/components/InputSelect/README.md create mode 100644 src/components/InputSelect/index.ts create mode 100644 src/components/InputSelect/input-select.spec.ts create mode 100644 src/components/InputSelect/types.ts 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` <form class="pdap-form" id="test" name="test"> <!--v-if--> <div class="pdap-input"> + <label for="name">Name</label> <!--v-if--> <input id="name" name="name" placeholder="Name" type="text"> - <label for="name">Name</label> </div> <div class="pdap-input"> + <label for="email">Email</label> <!--v-if--> <input id="email" name="email" placeholder="Email" type="text"> - <label for="email">Email</label> </div> <div class="pdap-input pdap-input-password"> + <label for="password">Password</label> <!--v-if--> <div class="pdap-input-password-wrapper"> <input id="password" name="password" placeholder="Password" type="password"> @@ -23,7 +24,6 @@ exports[`PdapFormV2 > calls submit event with form values on valid submission 1` </svg> </button> </div> - <label for="password">Password</label> </div> <div class="pdap-input pdap-input-checkbox"> <!--v-if--> @@ -37,16 +37,17 @@ exports[`PdapFormV2 > renders default error message when form has errors 1`] = ` <form class="pdap-form" id="test" name="test"> <div class="pdap-form-error-message">Please update this form to correct the errors</div> <div class="pdap-input pdap-input-error"> + <label for="name">Name</label> <div class="pdap-input-error-message">Value is required</div> <input id="name" name="name" placeholder="Name" type="text"> - <label for="name">Name</label> </div> <div class="pdap-input pdap-input-error"> + <label for="email">Email</label> <div class="pdap-input-error-message">Value is required</div> <input id="email" name="email" placeholder="Email" type="text"> - <label for="email">Email</label> </div> <div class="pdap-input pdap-input-password"> + <label for="password">Password</label> <!--v-if--> <div class="pdap-input-password-wrapper"> <input id="password" name="password" placeholder="Password" type="password"> @@ -56,7 +57,6 @@ exports[`PdapFormV2 > renders default error message when form has errors 1`] = ` </svg> </button> </div> - <label for="password">Password</label> </div> <div class="pdap-input pdap-input-checkbox"> <!--v-if--> @@ -76,16 +76,17 @@ exports[`PdapFormV2 > renders error message when errorMessage prop is provided 1 <form class="pdap-form" id="test" name="test"> <div class="pdap-form-error-message">Form Error</div> <div class="pdap-input"> + <label for="name">Name</label> <!--v-if--> <input id="name" name="name" placeholder="Name" type="text"> - <label for="name">Name</label> </div> <div class="pdap-input"> + <label for="email">Email</label> <!--v-if--> <input id="email" name="email" placeholder="Email" type="text"> - <label for="email">Email</label> </div> <div class="pdap-input pdap-input-password"> + <label for="password">Password</label> <!--v-if--> <div class="pdap-input-password-wrapper"> <input id="password" name="password" placeholder="Password" type="password"> @@ -95,7 +96,6 @@ exports[`PdapFormV2 > renders error message when errorMessage prop is provided 1 </svg> </button> </div> - <label for="password">Password</label> </div> <div class="pdap-input pdap-input-checkbox"> <!--v-if--> @@ -109,16 +109,17 @@ exports[`PdapFormV2 > renders the form element 1`] = ` <form class="pdap-form" id="test" name="test"> <!--v-if--> <div class="pdap-input"> + <label for="name">Name</label> <!--v-if--> <input id="name" name="name" placeholder="Name" type="text"> - <label for="name">Name</label> </div> <div class="pdap-input"> + <label for="email">Email</label> <!--v-if--> <input id="email" name="email" placeholder="Email" type="text"> - <label for="email">Email</label> </div> <div class="pdap-input pdap-input-password"> + <label for="password">Password</label> <!--v-if--> <div class="pdap-input-password-wrapper"> <input id="password" name="password" placeholder="Password" type="password"> @@ -128,7 +129,6 @@ exports[`PdapFormV2 > renders the form element 1`] = ` </svg> </button> </div> - <label for="password">Password</label> </div> <div class="pdap-input pdap-input-checkbox"> <!--v-if--> 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 }" > - <slot v-if="$slots.error" name="error" class="pdap-input-error-message" /> + <div v-if="$slots.error && error" class="pdap-input-error-message"> + <slot name="error" /> + </div> <div v-else-if="error" class="pdap-input-error-message">{{ error }}</div> <input diff --git a/src/components/InputPassword/PdapInputPassword.vue b/src/components/InputPassword/PdapInputPassword.vue index f54bf82..53ad743 100644 --- a/src/components/InputPassword/PdapInputPassword.vue +++ b/src/components/InputPassword/PdapInputPassword.vue @@ -3,7 +3,11 @@ class="pdap-input pdap-input-password" :class="{ ['pdap-input-error']: error }" > - <slot v-if="$slots.error" name="error" class="pdap-input-error-message" /> + <label v-if="$slots.label" :for="id"><slot name="label" /></label> + <label v-else-if="label" :for="id">{{ label }}</label> + <div v-if="$slots.error && error" class="pdap-input-error-message"> + <slot name="error" /> + </div> <div v-else-if="error" class="pdap-input-error-message">{{ error }}</div> <div class="pdap-input-password-wrapper"> @@ -25,9 +29,6 @@ <FontAwesomeIcon :icon="isMasked ? faEye : faEyeSlash" /> </button> </div> - - <label v-if="$slots.label" :for="id"><slot name="label" /></label> - <label v-else-if="label" :for="id">{{ label }}</label> </div> </template> 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 @@ +<template> + <div class="pdap-input" :class="{ 'pdap-input-error': error }"> + <label v-if="$slots.label" :id="`${name}-${id}-label`" :for="id"> + <slot name="label" /> + </label> + <label v-else-if="label" :id="`${name}-${id}-label`" :for="id"> + {{ label }} + </label> + <div v-if="$slots.error && error" class="pdap-input-error-message"> + <!-- TODO: aria-aware error handling?? Not just here but in other input components as well? --> + <slot name="error" /> + </div> + <div v-else-if="error" class="pdap-input-error-message">{{ error }}</div> + + <div + :id="id" + ref="selectRef" + v-on-click-outside="closeAndReturnFocus" + aria-controls="listbox" + :aria-expanded="isOpen" + :aria-labelledby="`${name}-${id}-label`" + class="pdap-custom-select" + :class="{ open: isOpen }" + role="combobox" + :tabindex="0" + v-bind="$attrs" + @click="toggleOpen" + @keydown="handleKeyDown" + > + <div class="selected-value"> + {{ selectedOption ? selectedOption.label : placeholder }} + </div> + <div class="arrow" :class="{ open: isOpen }" /> + <ul + v-show="isOpen" + ref="listRef" + class="pdap-custom-select-options" + role="listbox" + :tabindex="-1" + :aria-activedescendant=" + optionIds.get(focusedOptionIndex) ?? selectedOption?.label + " + > + <li + v-for="(option, index) in options" + :id="optionIds.get(index)" + :key="option.value + '_select-option'" + :ref="(el) => setOptionRef(el as HTMLLIElement, index)" + class="pdap-custom-select-option" + :class="{ selected: focusedOptionIndex === index }" + role="option" + :aria-selected="option.value === selectedOption?.value" + tabindex="0" + @click.stop="selectOption(option)" + @keydown.enter.stop="selectOption(option)" + @focus="focusedOptionIndex = index" + @mouseenter="focusedOptionIndex = index" + @mouseleave="undefined" + @blur="undefined" + > + {{ option.label }} + </li> + </ul> + </div> + </div> +</template> + +<script setup lang="ts"> +import { ref, inject, watch, computed, nextTick, useSlots } from 'vue'; +import { PdapSelectOption as Option, PdapInputSelectProps } from './types'; +import { PdapFormProvideV2 } from '../FormV2/types'; +import { provideKey } from '../FormV2/util'; +import { vOnClickOutside } from '../../directives'; + +const { name, options, id, label } = withDefaults( + defineProps<PdapInputSelectProps>(), + { + placeholder: 'Select an option', + } +); +const slots = useSlots(); +if (!slots.label && !label) + throw new Error( + 'All form inputs must have a label, passed as a slot or a prop' + ); + +const { setValues, values, v$ } = inject<PdapFormProvideV2>(provideKey)!; + +const isOpen = ref(false); +const selectedOption = ref<Option | null>(null); +const focusedOptionIndex = ref(-1); +const optionRefs = ref<Map<number, HTMLLIElement>>(new Map()); +const selectRef = ref(); + +const optionIds = computed(() => { + return new Map( + options.map(({ value }, index) => [index, `${name}-option-${value}`]) + ); +}); + +const error = computed(() => { + return v$.value[name]?.$error ? v$.value[name]?.$errors?.[0]?.$message : ''; +}); + +function toggleOpen() { + isOpen.value = !isOpen.value; +} + +function closeAndReturnFocus() { + if (isOpen.value) { + isOpen.value = false; + selectRef.value.focus(); + focusedOptionIndex.value = -1; + } +} + +function selectOption(option: Option) { + selectedOption.value = option; + closeAndReturnFocus(); +} + +function setOptionRef(el: HTMLLIElement | null, index: number) { + if (el) { + optionRefs.value.set(index, el); + } +} +function focusOption(index: number) { + const optionElement = optionRefs.value.get(index); + if (optionElement) { + // Using nextTick here to ensure DOM is fully updated first + nextTick(() => { + optionElement.focus(); + }); + } +} + +function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Tab') { + if (!event.shiftKey && focusedOptionIndex.value === options.length - 1) { + event.preventDefault(); + return; + } + + if (event.shiftKey && focusedOptionIndex.value === 0) { + event.preventDefault(); + closeAndReturnFocus(); + return; + } + + return; + } + + if (!isOpen.value) { + if (['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) { + event.preventDefault(); + toggleOpen(); + } + return; + } + + event.preventDefault(); + switch (event.key) { + case 'ArrowDown': + if (focusedOptionIndex.value === options.length - 1) break; + focusedOptionIndex.value = focusedOptionIndex.value + 1; + break; + case 'ArrowUp': + if (focusedOptionIndex.value <= 0) { + closeAndReturnFocus(); + break; + } + focusedOptionIndex.value = focusedOptionIndex.value - 1; + break; + case 'Enter': + if (focusedOptionIndex.value >= 0) { + selectOption(options[focusedOptionIndex.value]); + } else { + toggleOpen(); + } + break; + case 'Escape': + closeAndReturnFocus(); + break; + } +} + +watch( + () => selectedOption.value, + (newSelected) => { + // Update form values when state changes. + if (newSelected) { + setValues({ [name]: newSelected.value }); + } + } +); + +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) { + focusOption(nextIndexToFocus); + } + } +); + +watch( + () => isOpen.value, + (isNowOpen) => { + // If menu is opening, find selected option and focus it. + if (isNowOpen && selectedOption.value) { + const selected = options.find( + (opt) => opt.value === selectedOption?.value?.value + ); + if (!selected) return; + const index = options.indexOf(selected); + focusedOptionIndex.value = index; + } + } +); + +watch( + // Welcome to the land of edge-cases. + () => values.value, + // In the (unlikely, unrecommended, but sometimes unfortunately necessary) event of form values changing upstream from a parent component: + (formValuesUpdated) => { + // Case 0: Values are equivalent, or the change was made here, do nothing. + + /* + * Case 1: Value does not exist in form values object, meaning either: + ** a. it has not been set by the upstream change, or + ** b. has been changed to an empty string by `Form` after submit event + ** In either case, clear state or keep it clear + */ + if (!formValuesUpdated[name]) selectedOption.value = null; + + // Case 2 (rare): value has been programmatically updated upstream of `Form`, handle either: + if (formValuesUpdated[name] !== selectedOption?.value) + selectedOption.value = + // a. changed value exists as an option, we override state, or + options.find((opt) => opt.value === formValuesUpdated[name]) ?? + // b. changed value does not exist as an option, keep state value. + selectedOption.value; + } +); +</script> + +<style> +@tailwind components; + +@layer components { + .pdap-custom-select { + @apply relative w-full bg-neutral-50 dark:bg-neutral-950 border-2 border-solid border-neutral-500 cursor-pointer; + } + + .pdap-custom-select-options { + @apply absolute top-[115%] left-[-2px] w-[calc(100%+4px)] bg-neutral-50 dark:bg-neutral-950 border-solid border-2 border-neutral-500 max-h-48 overflow-y-auto z-20 p-1; + } + + .pdap-custom-select-option { + @apply text-neutral-950 dark:text-neutral-50 p-2 w-full max-w-full cursor-pointer; + } + + .pdap-custom-select-option:hover, + .pdap-custom-select-option:focus { + @apply bg-neutral-200 dark:bg-neutral-700; + } +} + +.selected-value { + @apply py-2 px-3 text-neutral-950 dark:text-neutral-50; +} + +.arrow { + @apply absolute top-1/2 right-3 w-0 h-0 border-l-8 border-r-8 border-t-8 border-solid border-x-transparent border-t-neutral-950 dark:border-t-neutral-50 transition-transform; +} + +.arrow.open { + @apply rotate-180; +} +</style> 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<sup>*</sup> | `Element` | slot content to be rendered as error | | +| `label` | yes, if label prop not passed | `Element` | slot content to be rendered as label | | + +<sup>*</sup> 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 +<template> + <FormV2 + id="form-id" + name="ice-cream-preference" + :schema="SCHEMA" + @submit="(values) => onSubmit({ values })" + @change="(values, event) => onChange({ values, event })" + > + <!-- Other inputs... --> + + <InputSelect + :id="INPUT_SELECT_NAME" + :name="INPUT_SELECT_NAME" + :options="ICE_CREAM_FLAVORS" + placeholder="Select flavor" + > + <template #label> + <h4>What is your favorite flavor?</h4> + </template> + </InputSelect> + </FormV2> +</template> + +<script setup> +import { InputSelect, FormV2 } from 'pdap-design-system'; + +const ICE_CREAM_FLAVORS = [ + { + value: 'vanilla', + label: 'Vanilla', + }, + { + value: 'chocolate', + label: 'Chocolate', + }, + { + value: 'neapolitan', + label: 'Neapolitan', + }, + { + value: 'rocky-road', + label: 'Rocky Road', + }, + { + value: 'chunky-monkey', + label: 'Chunky Monkey', + }, + { + value: 'mint-choc', + label: 'Mint Chocolate Chip', + }, +]; + +</script> + +... +``` 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 @@ <template> <div class="pdap-input" :class="{ ['pdap-input-error']: error }"> - <slot v-if="$slots.error" name="error" class="pdap-input-error-message" /> + <label v-if="$slots.label" :for="id"><slot name="label" /></label> + <label v-else-if="label" :for="id">{{ label }}</label> + <div v-if="$slots.error && error" class="pdap-input-error-message"> + <slot name="error" /> + </div> <div v-else-if="error" class="pdap-input-error-message">{{ error }}</div> <input @@ -12,9 +16,6 @@ type="text" @input="onInput" /> - - <label v-if="$slots.label" :for="id"><slot name="label" /></label> - <label v-else-if="label" :for="id">{{ label }}</label> </div> </template> 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" > <template #label> <h4>Your name</h4> @@ -34,6 +34,24 @@ label="Do you like ice cream?" /> + <InputSelect + :id="INPUT_SELECT_NAME" + :name="INPUT_SELECT_NAME" + :options="ICE_CREAM_FLAVORS" + placeholder="Select flavor" + > + <template #label> + <h4>What is your favorite flavor?</h4> + </template> + <template #error> + <p class="p-4"> + Custom error slot with + <a class="text-wineneutral-50" href="#">a link</a> and more padding + than one would usually care for. Only rendered if an error exists. + </p> + </template> + </InputSelect> + <Button type="submit">Submit</Button> </FormV2> </main> @@ -45,11 +63,41 @@ import { FormV2 } from '../../components/FormV2'; import { InputText } from '../../components/InputText'; import { InputCheckbox } from '../../components/InputCheckbox'; import { InputPassword } from '../../components/InputPassword'; +import { InputSelect } from '../../components/InputSelect'; const INPUT_CHECKBOX_NAME = 'ice-cream'; +const INPUT_TEXT_PLACEHOLDER = 'Paul'; const INPUT_TEXT_NAME = 'first-name'; const INPUT_PASSWORD_NAME = 'password'; -const PLACEHOLDER = 'Paul'; +const INPUT_SELECT_NAME = 'flavors'; + +const ICE_CREAM_FLAVORS = [ + { + value: 'vanilla', + label: 'Vanilla', + }, + { + value: 'chocolate', + label: 'Chocolate', + }, + { + value: 'neapolitan', + label: 'Neapolitan', + }, + { + value: 'rocky-road', + label: 'Rocky Road', + }, + { + value: 'chunky-monkey', + label: 'Chunky Monkey', + }, + { + value: 'mint-choc', + label: 'Mint Chocolate Chip', + }, +]; + const SCHEMA = [ { name: INPUT_TEXT_NAME, @@ -71,6 +119,15 @@ const SCHEMA = [ }, }, }, + { + name: INPUT_SELECT_NAME, + validators: { + required: { + message: 'Please select your favorite flavor of ice cream.', + value: true, + }, + }, + }, ]; </script> diff --git a/src/styles/components.css b/src/styles/components.css index 3f9946e..19642a1 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -68,7 +68,8 @@ @apply justify-start; } - .pdap-input-error input { + .pdap-input-error input, + .pdap-input-error div[role="combobox"] { @apply border-red-800 dark:border-red-300; }