(), {});
const errorMessageId = computed(() => `pdap-${props.name}-input-error`);
-
diff --git a/src/components/Input/__snapshots__/input.spec.ts.snap b/src/components/Input/__snapshots__/input.spec.ts.snap
index f0913c8..52e20c3 100644
--- a/src/components/Input/__snapshots__/input.spec.ts.snap
+++ b/src/components/Input/__snapshots__/input.spec.ts.snap
@@ -4,7 +4,11 @@ exports[`Input component > Renders checkbox input in error state 1`] = `
`;
@@ -12,7 +16,11 @@ exports[`Input component > Renders checkbox input in okay state 1`] = `
- test this checkbox input
+ test this checkbox input
+
+
+
+
`;
@@ -20,7 +28,11 @@ exports[`Input component > Renders password input in error state 1`] = `
`;
@@ -28,7 +40,11 @@ exports[`Input component > Renders password input in okay state 1`] = `
- test this text input
+ test this text input
+
+
+
+
`;
@@ -36,7 +52,11 @@ exports[`Input component > Renders text input in error state 1`] = `
error message
-
test this text input
+
test this text input
+
+
+
+
`;
@@ -44,6 +64,10 @@ exports[`Input component > Renders text input in okay state 1`] = `
- test this text input
+ test this text input
+
+
+
+
`;
diff --git a/src/components/InputCheckbox/PdapInputCheckbox.vue b/src/components/InputCheckbox/PdapInputCheckbox.vue
new file mode 100644
index 0000000..c47c6a9
--- /dev/null
+++ b/src/components/InputCheckbox/PdapInputCheckbox.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
diff --git a/src/components/InputCheckbox/index.ts b/src/components/InputCheckbox/index.ts
new file mode 100644
index 0000000..ebdb38b
--- /dev/null
+++ b/src/components/InputCheckbox/index.ts
@@ -0,0 +1 @@
+export { default as InputCheckbox } from './PdapInputCheckbox.vue';
diff --git a/src/components/InputCheckbox/types.ts b/src/components/InputCheckbox/types.ts
new file mode 100644
index 0000000..691e4ab
--- /dev/null
+++ b/src/components/InputCheckbox/types.ts
@@ -0,0 +1,6 @@
+export interface PdapInputCheckboxProps {
+ id: string;
+ label?: string;
+ name: string;
+ defaultChecked?: boolean;
+}
diff --git a/src/components/InputDatePicker/PdapInputDatePicker.vue b/src/components/InputDatePicker/PdapInputDatePicker.vue
new file mode 100644
index 0000000..dafd56b
--- /dev/null
+++ b/src/components/InputDatePicker/PdapInputDatePicker.vue
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
diff --git a/src/components/InputDatePicker/README.md b/src/components/InputDatePicker/README.md
new file mode 100644
index 0000000..37e4792
--- /dev/null
+++ b/src/components/InputDatePicker/README.md
@@ -0,0 +1,53 @@
+# InputSelect
+Date picker component. Uses Vue3 Date Picker library under the hood.
+
+## Props - required
+
+| name | required? | types | description | default |
+| ------- | ----------------------------- | -------- | ------------- | ------- |
+| `id` | yes | `string` | id attr | |
+| `label` | yes, if label slot not passed | `string` | label content | |
+| `name` | yes | `string` | name attr | |
+
+## Props - Vue3 Date Picker
+The props interface extends the underlying component interface, so [all props available on the Vue 3 Date Picker component](https://vue3datepicker.com/props/modes/) are available to be passed.
+
+## Slots
+
+| name | required? | types | description | default |
+| ------- | ----------------------------- | --------- | ------------------------------------ | ------- |
+| `error` | no* | `Element` | slot content to be rendered as error | |
+| `label` | yes, if label prop not passed | `Element` | slot content to be rendered as label | |
+
+* Note: The error message is determined by Vuelidate via our form validation schema. If the error UI needs to be more complicated than a string that can be passed with the schema, pass an `\#error` slot and it will override the string.
+
+## Example
+
+```vue
+
+ onSubmit({ values })"
+ @change="(values, event) => onChange({ values, event })"
+ >
+
+
+
+ When will you next consume ice cream?
+
+
+
+
+
+
+
+...
+```
diff --git a/src/components/InputDatePicker/__snapshots__/input-date-picker.spec.ts.snap b/src/components/InputDatePicker/__snapshots__/input-date-picker.spec.ts.snap
new file mode 100644
index 0000000..67a04ee
--- /dev/null
+++ b/src/components/InputDatePicker/__snapshots__/input-date-picker.spec.ts.snap
@@ -0,0 +1,50 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`PdapInputDatePicker > Rendering > does not render label when neither prop nor slot is provided 1`] = `
+
+
+
+
+
+`;
+
+exports[`PdapInputDatePicker > Rendering > renders complex label slot content 1`] = `
+
+`;
+
+exports[`PdapInputDatePicker > Rendering > renders error slot when slot is provided 1`] = `
+
+`;
+
+exports[`PdapInputDatePicker > Rendering > renders label prop when no slot is provided 1`] = `
+
+ Label from prop
+
+
+
+`;
+
+exports[`PdapInputDatePicker > Rendering > renders label slot when provided 1`] = `
+
+
+ Custom Label Content
+
+
+
+
+`;
diff --git a/src/components/InputDatePicker/index.ts b/src/components/InputDatePicker/index.ts
new file mode 100644
index 0000000..576327b
--- /dev/null
+++ b/src/components/InputDatePicker/index.ts
@@ -0,0 +1 @@
+export { default as InputDatePicker } from './PdapInputDatePicker.vue';
diff --git a/src/components/InputDatePicker/input-date-picker.spec.ts b/src/components/InputDatePicker/input-date-picker.spec.ts
new file mode 100644
index 0000000..04389cb
--- /dev/null
+++ b/src/components/InputDatePicker/input-date-picker.spec.ts
@@ -0,0 +1,349 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { mount, VueWrapper } from '@vue/test-utils';
+import PdapInputDatePicker from './PdapInputDatePicker.vue';
+import { provideKey } from '../FormV2/util';
+import VueDatePicker from '@vuepic/vue-datepicker';
+import { ref } from 'vue';
+
+describe('PdapInputDatePicker', () => {
+ let wrapper: VueWrapper;
+ const mockSetValues = vi.fn();
+ const mockValues = ref({});
+ const mockV$ = ref({
+ testName: {
+ $error: false,
+ $errors: [],
+ },
+ });
+
+ const defaultProps = {
+ name: 'testName',
+ id: 'test-id',
+ label: 'Test Label',
+ };
+
+ const removeEventListener = vi.fn();
+ // Mock window.matchMedia
+ const mockMatchMedia = vi.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener,
+ dispatchEvent: vi.fn(),
+ }));
+
+ beforeEach(() => {
+ global.MediaQueryListEvent = vi
+ .fn()
+ .mockImplementation((type, eventInitDict) => ({
+ type,
+ matches: eventInitDict.matches,
+ media: '',
+ target: {
+ matches: eventInitDict.matches,
+ },
+ }));
+
+ window.matchMedia = mockMatchMedia;
+ wrapper = mount(PdapInputDatePicker, {
+ props: defaultProps,
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ describe('Rendering', () => {
+ it('renders correctly with default props', () => {
+ expect(wrapper.exists()).toBe(true);
+ expect(wrapper.find('label').text()).toBe('Test Label');
+ expect(wrapper.findComponent(VueDatePicker).exists()).toBe(true);
+ });
+
+ it('renders label prop when no slot is provided', () => {
+ wrapper = mount(PdapInputDatePicker, {
+ props: {
+ ...defaultProps,
+ label: 'Label from prop',
+ },
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+
+ const label = wrapper.find(`label[for="${defaultProps.id}"]`);
+ expect(label.exists()).toBe(true);
+ expect(label.text()).toBe('Label from prop');
+ expect(label.attributes('id')).toBe(
+ `${defaultProps.name}-${defaultProps.id}-label`
+ );
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('renders label slot when provided', () => {
+ wrapper = mount(PdapInputDatePicker, {
+ props: {
+ ...defaultProps,
+ label: 'Label from prop', // This should be ignored when slot is present
+ },
+ slots: {
+ label: 'Custom Label Content ',
+ },
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+
+ const label = wrapper.find(`label[for="${defaultProps.id}"]`);
+ expect(label.exists()).toBe(true);
+ expect(label.find('.custom-label').exists()).toBe(true);
+ expect(label.text()).toBe('Custom Label Content');
+ expect(label.text()).not.toBe('Label from prop');
+ expect(label.attributes('id')).toBe(
+ `${defaultProps.name}-${defaultProps.id}-label`
+ );
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('does not render label when neither prop nor slot is provided', () => {
+ wrapper = mount(PdapInputDatePicker, {
+ props: {
+ name: 'testName',
+ id: 'test-id',
+ // label prop intentionally omitted
+ },
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+
+ expect(wrapper.find('label').exists()).toBe(false);
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('renders complex label slot content', () => {
+ wrapper = mount(PdapInputDatePicker, {
+ props: {
+ ...defaultProps,
+ },
+ slots: {
+ label: `
+
+ Complex Label
+ * Required field
+
+ `,
+ },
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+
+ const label = wrapper.find(`label[for="${defaultProps.id}"]`);
+ expect(label.exists()).toBe(true);
+ expect(label.find('.complex-label').exists()).toBe(true);
+ expect(label.find('.label-title').text()).toBe('Complex Label');
+ expect(label.find('.label-hint').text()).toBe('* Required field');
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('renders error slot when slot is provided', () => {
+ mockV$.value = {
+ ...mockV$.value,
+ testName: {
+ $error: true,
+ // @ts-expect-error
+ $errors: [{ $message: 'Error Message' }],
+ },
+ };
+
+ wrapper = mount(PdapInputDatePicker, {
+ props: {
+ ...defaultProps,
+ },
+ slots: {
+ error: 'Custom Error Message ',
+ },
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+
+ const errorElement = wrapper.find(
+ '.pdap-input-error-message .custom-error'
+ );
+ expect(errorElement.exists()).toBe(true);
+ expect(errorElement.text()).toBe('Custom Error Message');
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+ });
+
+ describe('Form Integration', () => {
+ it('calls setValues when date changes', async () => {
+ const datePicker = wrapper.findComponent(VueDatePicker);
+ const newDate = new Date('2024-01-01');
+
+ await datePicker.vm.$emit('update:modelValue', newDate);
+
+ expect(mockSetValues).toHaveBeenCalledWith({
+ [defaultProps.name]: newDate,
+ });
+ });
+
+ it('updates date when form values change externally', async () => {
+ const newDate = new Date('2024-01-01');
+ mockValues.value = {
+ [defaultProps.name]: newDate,
+ };
+
+ await wrapper.vm.$nextTick();
+
+ // @ts-expect-error
+ expect(wrapper.vm.date).toEqual(newDate);
+ });
+
+ it('clears date when form value is removed', async () => {
+ mockValues.value = {
+ [defaultProps.name]: undefined,
+ };
+
+ await wrapper.vm.$nextTick();
+
+ // @ts-expect-error
+ expect(wrapper.vm.date).toBeUndefined();
+ });
+ });
+
+ describe('Dark Mode', () => {
+ it('initializes with system dark mode preference', () => {
+ const darkModeMatchMedia = vi.fn().mockImplementation(() => ({
+ matches: true,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }));
+ window.matchMedia = darkModeMatchMedia;
+
+ wrapper = mount(PdapInputDatePicker, {
+ props: defaultProps,
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ stubs: {
+ VueDatePicker: true,
+ },
+ },
+ });
+
+ // @ts-expect-error
+ expect(wrapper.vm.darkModePreference).toBe(true);
+ });
+
+ it('updates dark mode preference when system preference changes', async () => {
+ const mockEvent = {
+ matches: true,
+ type: 'change',
+ } as MediaQueryListEvent;
+
+ // @ts-expect-error
+ wrapper.vm.updateColorMode(mockEvent);
+ await wrapper.vm.$nextTick();
+
+ // @ts-expect-error
+ expect(wrapper.vm.darkModePreference).toBe(true);
+ });
+ });
+
+ describe('Validation', () => {
+ it('shows validation error from v$', async () => {
+ mockV$.value = {
+ testName: {
+ $error: true,
+ // @ts-expect-error
+ $errors: [{ $message: 'Validation error' }],
+ },
+ };
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('.pdap-input-error-message').text()).toBe(
+ 'Validation error'
+ );
+ });
+ });
+
+ describe('Cleanup', () => {
+ it('removes event listener on unmount', async () => {
+ wrapper.unmount();
+
+ expect(removeEventListener).toHaveBeenCalledWith(
+ 'change',
+ expect.any(Function)
+ );
+ });
+ });
+});
diff --git a/src/components/InputDatePicker/types.ts b/src/components/InputDatePicker/types.ts
new file mode 100644
index 0000000..83bfd57
--- /dev/null
+++ b/src/components/InputDatePicker/types.ts
@@ -0,0 +1,7 @@
+import { VueDatePickerProps } from '@vuepic/vue-datepicker';
+
+export interface PdapDatePickerProps extends VueDatePickerProps {
+ id: string;
+ label?: string;
+ name: string;
+}
diff --git a/src/components/InputPassword/PdapInputPassword.vue b/src/components/InputPassword/PdapInputPassword.vue
new file mode 100644
index 0000000..7662ec6
--- /dev/null
+++ b/src/components/InputPassword/PdapInputPassword.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
diff --git a/src/components/InputPassword/index.ts b/src/components/InputPassword/index.ts
new file mode 100644
index 0000000..0e15271
--- /dev/null
+++ b/src/components/InputPassword/index.ts
@@ -0,0 +1 @@
+export { default as InputPassword } from './PdapInputPassword.vue';
diff --git a/src/components/InputRadio/PdapInputRadio.vue b/src/components/InputRadio/PdapInputRadio.vue
new file mode 100644
index 0000000..075538c
--- /dev/null
+++ b/src/components/InputRadio/PdapInputRadio.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+ {{ label }}
+
+
+
+
diff --git a/src/components/InputRadio/README.md b/src/components/InputRadio/README.md
new file mode 100644
index 0000000..7c1d9e4
--- /dev/null
+++ b/src/components/InputRadio/README.md
@@ -0,0 +1,53 @@
+# InputRadio
+Radio input. Designed to be wrapped with `RadioGroup`
+
+## Props - required
+
+| name | required? | types | description | default |
+| ---------------- | ----------------------------- | --------- | --------------------------------------------- | ------- |
+| `defaultChecked` | no | `boolean` | radio is checked by default. Only 1 per group | |
+| `id` | yes | `string` | id attr | |
+| `label` | yes, if label slot not passed | `string` | label content | |
+| `name` | yes | `string` | name attr | |
+
+## Slots
+
+| name | required? | types | description | default |
+| ------- | ----------------------------- | --------- | ------------------------------------ | ------- |
+| `label` | yes, if label prop not passed | `Element` | slot content to be rendered as label | |
+
+## Example
+
+```vue
+
+ onSubmit({ values })"
+ @change="(values, event) => onChange({ values, event })"
+ >
+
+
+
+ Select another flavor, with radio buttons this time!
+
+
+
+
+
+
+
+
+...
+```
diff --git a/src/components/InputRadio/index.ts b/src/components/InputRadio/index.ts
new file mode 100644
index 0000000..e8e5c4c
--- /dev/null
+++ b/src/components/InputRadio/index.ts
@@ -0,0 +1 @@
+export { default as InputRadio } from './PdapInputRadio.vue';
diff --git a/src/components/InputRadio/input-radio.spec.ts b/src/components/InputRadio/input-radio.spec.ts
new file mode 100644
index 0000000..a7d00a2
--- /dev/null
+++ b/src/components/InputRadio/input-radio.spec.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount, VueWrapper } from '@vue/test-utils';
+import PdapInputRadio from './PdapInputRadio.vue';
+import { provideKey } from '../FormV2/util';
+import { ref } from 'vue';
+
+describe('PdapInputRadio', () => {
+ 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',
+ value: 'test-value',
+ };
+
+ beforeEach(() => {
+ mockSetValues.mockClear();
+ });
+
+ const createWrapper = (props = {}, provide = {}, slots = {}) => {
+ return mount(PdapInputRadio, {
+ props: {
+ ...defaultProps,
+ ...props,
+ },
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ ...provide,
+ },
+ },
+ },
+ slots: { ...slots },
+ });
+ };
+
+ it('renders correctly with default props', () => {
+ wrapper = createWrapper();
+ expect(wrapper.find('input[type="radio"]').exists()).toBe(true);
+ expect(wrapper.find('label').text()).toBe('Test Label');
+ });
+
+ it('renders with slot label instead of prop label', () => {
+ wrapper = createWrapper(
+ {},
+ {},
+ {
+ label: 'Slot Label ',
+ }
+ );
+ expect(wrapper.find('label span').text()).toBe('Slot Label');
+ });
+
+ it('throws error when no label passed as slot or prop', async () => {
+ expect(() => {
+ wrapper = createWrapper({ label: undefined }, {}, {});
+ }).toThrow('All form inputs must have a label, passed as a slot or a prop');
+ });
+
+ it('emits input event and calls setValues when changed', async () => {
+ wrapper = createWrapper();
+ const input = wrapper.find('input');
+ await input.setValue(true);
+ expect(mockSetValues).toHaveBeenCalledWith({
+ [defaultProps.name]: defaultProps.value,
+ });
+ });
+
+ it('renders with defaultChecked prop', () => {
+ wrapper = createWrapper({ defaultChecked: true });
+ expect(wrapper.find('input').element.defaultChecked).toBe(true);
+ });
+});
diff --git a/src/components/InputRadio/types.ts b/src/components/InputRadio/types.ts
new file mode 100644
index 0000000..723264b
--- /dev/null
+++ b/src/components/InputRadio/types.ts
@@ -0,0 +1,7 @@
+export interface PdapInputRadioProps {
+ id: string;
+ label?: string;
+ name: string;
+ defaultChecked?: boolean;
+ value: string;
+}
diff --git a/src/components/InputRadioGroup/PdapInputRadioGroup.vue b/src/components/InputRadioGroup/PdapInputRadioGroup.vue
new file mode 100644
index 0000000..6362f0b
--- /dev/null
+++ b/src/components/InputRadioGroup/PdapInputRadioGroup.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
diff --git a/src/components/InputRadioGroup/index.ts b/src/components/InputRadioGroup/index.ts
new file mode 100644
index 0000000..0949494
--- /dev/null
+++ b/src/components/InputRadioGroup/index.ts
@@ -0,0 +1 @@
+export { default as RadioGroup } from './PdapInputRadioGroup.vue';
diff --git a/src/components/InputRadioGroup/input-radio-group.spec.ts b/src/components/InputRadioGroup/input-radio-group.spec.ts
new file mode 100644
index 0000000..95c2b4e
--- /dev/null
+++ b/src/components/InputRadioGroup/input-radio-group.spec.ts
@@ -0,0 +1,78 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount, VueWrapper } from '@vue/test-utils';
+import RadioGroup from './PdapInputRadioGroup.vue';
+import { provideKey } from '../FormV2/util';
+import { ref } from 'vue';
+
+describe('RadioGroup', () => {
+ let wrapper: VueWrapper;
+ const mockSetValues = vi.fn();
+ const mockValues = ref({});
+ const mockV$ = ref({
+ testName: {
+ $error: false,
+ $errors: [],
+ },
+ });
+
+ const defaultProps = {
+ name: 'testName',
+ };
+
+ beforeEach(() => {
+ mockSetValues.mockClear();
+ });
+
+ const createWrapper = (props = {}, slots = {}) => {
+ return mount(RadioGroup, {
+ props: {
+ ...defaultProps,
+ ...props,
+ },
+ slots,
+ global: {
+ provide: {
+ [provideKey as symbol]: {
+ setValues: mockSetValues,
+ values: mockValues,
+ v$: mockV$,
+ },
+ },
+ },
+ });
+ };
+
+ it('renders correctly with default props', () => {
+ wrapper = createWrapper();
+ expect(wrapper.find('.pdap-input-radio-group').exists()).toBe(true);
+ });
+
+ it('renders slot content', () => {
+ wrapper = createWrapper(
+ {},
+ {
+ default: 'Test Content
',
+ }
+ );
+ expect(wrapper.find('.test-content').exists()).toBe(true);
+ });
+
+ it('shows error message when error exists', async () => {
+ mockV$.value.testName.$error = true;
+ // @ts-expect-error
+ mockV$.value.testName.$errors = [{ $message: 'Test error message' }];
+ wrapper = createWrapper();
+ expect(wrapper.find('.pdap-input-error').exists()).toBe(true);
+ });
+
+ it('shows custom error slot when provided and error exists', () => {
+ mockV$.value.testName.$error = true;
+ wrapper = createWrapper(
+ {},
+ {
+ error: 'Custom Error
',
+ }
+ );
+ expect(wrapper.find('.custom-error').exists()).toBe(true);
+ });
+});
diff --git a/src/components/InputSelect/PdapInputSelect.vue b/src/components/InputSelect/PdapInputSelect.vue
new file mode 100644
index 0000000..5e3d0fb
--- /dev/null
+++ b/src/components/InputSelect/PdapInputSelect.vue
@@ -0,0 +1,371 @@
+
+
+
+
+
+
+
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
+
+ onSubmit({ values })"
+ @change="(values, event) => onChange({ values, event })"
+ >
+
+
+
+
+ What is your favorite flavor?
+
+
+
+
+
+
+
+...
+```
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..2ad40af
--- /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(0);
+ });
+
+ 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..1928495
--- /dev/null
+++ b/src/components/InputSelect/types.ts
@@ -0,0 +1,17 @@
+export interface PdapSelectOption {
+ value: string;
+ label: string;
+}
+
+export interface PdapInputSelectProps {
+ id: string;
+ label?: string;
+ name: string;
+ placeholder?: string;
+ options: PdapSelectOption[];
+ combobox?: boolean;
+ filter?: (
+ searchText: string,
+ options: PdapSelectOption[]
+ ) => PdapSelectOption[];
+}
diff --git a/src/components/InputText/PdapInputText.vue b/src/components/InputText/PdapInputText.vue
new file mode 100644
index 0000000..d8952d9
--- /dev/null
+++ b/src/components/InputText/PdapInputText.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
diff --git a/src/components/InputText/index.ts b/src/components/InputText/index.ts
new file mode 100644
index 0000000..9cfa813
--- /dev/null
+++ b/src/components/InputText/index.ts
@@ -0,0 +1 @@
+export { default as InputText } from './PdapInputText.vue';
diff --git a/src/components/InputText/types.ts b/src/components/InputText/types.ts
new file mode 100644
index 0000000..99ba44b
--- /dev/null
+++ b/src/components/InputText/types.ts
@@ -0,0 +1,6 @@
+export interface PdapInputTextProps {
+ id: string;
+ label?: string;
+ name: string;
+ placeholder?: string;
+}
diff --git a/src/components/InputTextArea/PdapInputTextArea.vue b/src/components/InputTextArea/PdapInputTextArea.vue
new file mode 100644
index 0000000..3867186
--- /dev/null
+++ b/src/components/InputTextArea/PdapInputTextArea.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
diff --git a/src/components/InputTextArea/index.ts b/src/components/InputTextArea/index.ts
new file mode 100644
index 0000000..de92cf5
--- /dev/null
+++ b/src/components/InputTextArea/index.ts
@@ -0,0 +1 @@
+export { default as InputTextArea } from './PdapInputTextArea.vue';
diff --git a/src/components/InputTextArea/types.ts b/src/components/InputTextArea/types.ts
new file mode 100644
index 0000000..5524af8
--- /dev/null
+++ b/src/components/InputTextArea/types.ts
@@ -0,0 +1,6 @@
+export interface PdapInputTextAreaProps {
+ id: string;
+ label?: string;
+ name: string;
+ placeholder?: string;
+}
diff --git a/src/components/Nav/PdapNav.vue b/src/components/Nav/PdapNav.vue
index 7c86e52..3b11aed 100644
--- a/src/components/Nav/PdapNav.vue
+++ b/src/components/Nav/PdapNav.vue
@@ -12,16 +12,12 @@
@click="toggleIsExpanded"
@keyup.enter="toggleIsExpanded"
>
-
-
-
+
-
+
@@ -52,8 +48,9 @@
class="pdap-nav-link"
:to="link.path"
@click="toggleIsExpanded"
- >{{ link.text }}
+ {{ link.text }}
+
@@ -61,6 +58,8 @@
diff --git a/src/components/RecordTypeIcon/__snapshots__/recordTypeIcon.spec.ts.snap b/src/components/RecordTypeIcon/__snapshots__/recordTypeIcon.spec.ts.snap
new file mode 100644
index 0000000..10481c5
--- /dev/null
+++ b/src/components/RecordTypeIcon/__snapshots__/recordTypeIcon.spec.ts.snap
@@ -0,0 +1,45 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`RecordTypeIcon > does not render an icon for an invalid record type 1`] = ``;
+
+exports[`RecordTypeIcon > handles record types with ampersand correctly 1`] = `
+
+
+
+`;
+
+exports[`RecordTypeIcon > handles top-level record types correctly 1`] = `
+
+
+
+`;
+
+exports[`RecordTypeIcon > handles top-level record types correctly 2`] = `
+
+
+
+`;
+
+exports[`RecordTypeIcon > handles top-level record types correctly 3`] = `
+
+
+
+`;
+
+exports[`RecordTypeIcon > handles top-level record types correctly 4`] = `
+
+
+
+`;
+
+exports[`RecordTypeIcon > handles top-level record types correctly 5`] = `
+
+
+
+`;
+
+exports[`RecordTypeIcon > renders the correct icon for a valid record type 1`] = `
+
+
+
+`;
diff --git a/src/components/RecordTypeIcon/index.ts b/src/components/RecordTypeIcon/index.ts
new file mode 100644
index 0000000..1cea958
--- /dev/null
+++ b/src/components/RecordTypeIcon/index.ts
@@ -0,0 +1 @@
+export { default as RecordTypeIcon } from './RecordTypeIcon.vue';
diff --git a/src/components/RecordTypeIcon/recordTypeIcon.spec.ts b/src/components/RecordTypeIcon/recordTypeIcon.spec.ts
new file mode 100644
index 0000000..5acb11d
--- /dev/null
+++ b/src/components/RecordTypeIcon/recordTypeIcon.spec.ts
@@ -0,0 +1,96 @@
+import { shallowMount } from '@vue/test-utils';
+import RecordTypeIcon from './RecordTypeIcon.vue';
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
+import {
+ faPersonMilitaryToPerson,
+ faPersonMilitaryPointing,
+ faBuildingShield,
+ faFileShield,
+ faBuildingColumns,
+} from '@fortawesome/free-solid-svg-icons';
+import { describe, expect, it } from 'vitest';
+
+describe('RecordTypeIcon', () => {
+ it('renders the correct icon for a valid record type', () => {
+ const recordType = 'Incident Reports';
+ const wrapper = shallowMount(RecordTypeIcon, {
+ props: { recordType },
+ global: {
+ stubs: {
+ FontAwesomeIcon,
+ },
+ },
+ });
+
+ expect(wrapper.findComponent(FontAwesomeIcon).props('icon')).toEqual(
+ faPersonMilitaryToPerson
+ );
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('does not render an icon for an invalid record type', () => {
+ const recordType = 'Invalid Record Type';
+ const wrapper = shallowMount(RecordTypeIcon, {
+ props: { recordType },
+ global: {
+ stubs: {
+ FontAwesomeIcon,
+ },
+ },
+ });
+
+ expect(wrapper.findComponent(FontAwesomeIcon).exists()).toBe(false);
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('handles record types with ampersand correctly', () => {
+ const recordType = 'Police & public interactions';
+ const wrapper = shallowMount(RecordTypeIcon, {
+ props: { recordType },
+ global: {
+ stubs: {
+ FontAwesomeIcon,
+ },
+ },
+ });
+
+ expect(wrapper.findComponent(FontAwesomeIcon).props('icon')).toEqual(
+ faPersonMilitaryToPerson
+ );
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('handles top-level record types correctly', () => {
+ const recordTypes = [
+ 'Police & public interactions',
+ 'Info about officers',
+ 'Info about agencies',
+ 'Agency-published resources',
+ 'Jails & courts',
+ ];
+
+ const expectedIcons = [
+ faPersonMilitaryToPerson,
+ faPersonMilitaryPointing,
+ faBuildingShield,
+ faFileShield,
+ faBuildingColumns,
+ ];
+
+ recordTypes.forEach((recordType, index) => {
+ const wrapper = shallowMount(RecordTypeIcon, {
+ props: { recordType },
+ global: {
+ stubs: {
+ FontAwesomeIcon,
+ },
+ },
+ });
+
+ expect(wrapper.findComponent(FontAwesomeIcon).props('icon')).toEqual(
+ expectedIcons[index]
+ );
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/components/RecordTypeIcon/util.ts b/src/components/RecordTypeIcon/util.ts
new file mode 100644
index 0000000..4385800
--- /dev/null
+++ b/src/components/RecordTypeIcon/util.ts
@@ -0,0 +1,14 @@
+/**
+ * Creates map with type inference
+ *
+ * UNUSED FOR NOW, but we may need it later
+ */
+// export function ReadonlyMapWithStringKeys(
+// iterable: Iterable<[K, V]>
+// ): ReadonlyMap {
+// return new Map(iterable);
+// }
+
+// type MapKeys = typeof <> extends ReadonlyMap
+// ? K
+// : never;
diff --git a/src/components/index.ts b/src/components/index.ts
index 14e16a2..d4290c6 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,8 +1,17 @@
export { Button } from './Button';
export { ErrorBoundary } from './ErrorBoundary';
-export { Footer } from './Footer';
+export * from './Footer';
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 { InputRadio } from './InputRadio';
+export { RadioGroup } from './InputRadioGroup';
+export { InputSelect } from './InputSelect';
+export { InputText } from './InputText';
+export { InputTextArea } from './InputTextArea';
export { Header } from './Header';
export { Nav } from './Nav';
export { QuickSearchForm } from './QuickSearchForm';
@@ -10,3 +19,4 @@ export { TileIcon } from './TileIcon';
export { Dropdown } from './Dropdown';
export { Breadcrumbs } from './Breadcrumbs';
export { Spinner } from './Spinner';
+export { RecordTypeIcon } from './RecordTypeIcon';
diff --git a/src/demo/App.vue b/src/demo/App.vue
index a96b1e7..1600518 100644
--- a/src/demo/App.vue
+++ b/src/demo/App.vue
@@ -45,5 +45,5 @@ provide('navLinks', links);
-
+
diff --git a/src/demo/pages/ComponentDemo.vue b/src/demo/pages/ComponentDemo.vue
index 5ac7ff1..655365f 100644
--- a/src/demo/pages/ComponentDemo.vue
+++ b/src/demo/pages/ComponentDemo.vue
@@ -1,5 +1,5 @@
-
+
PDAP component library demo. This is a top-level heading.
And this is a paragraph. By default it has a max width of 45ch.
@@ -195,6 +195,7 @@
Form