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;
 	}