From 470e406914e484b58c90c6fc0920377e4ce2f8d2 Mon Sep 17 00:00:00 2001 From: Joshua Graber Date: Tue, 28 Jan 2025 17:15:04 -0500 Subject: [PATCH] feat(components): async typeahead dropdown component adds typeahead and associated tests resolves #133 --- docs/components.md | 1 + .../AsyncTypeahead/AsyncTypeahead.vue | 264 ++++++++++++++++++ src/components/AsyncTypeahead/README.md | 13 + .../__snapshots__/typeaheadinput.spec.ts.snap | 94 +++++++ src/components/AsyncTypeahead/index.ts | 1 + .../AsyncTypeahead/typeaheadinput.spec.ts | 224 +++++++++++++++ src/components/AsyncTypeahead/types.ts | 7 + src/components/index.ts | 1 + src/demo/pages/TypeaheadDemo.vue | 116 ++++++++ src/demo/router.js | 6 + src/index.ts | 1 + 11 files changed, 728 insertions(+) create mode 100644 src/components/AsyncTypeahead/AsyncTypeahead.vue create mode 100644 src/components/AsyncTypeahead/README.md create mode 100644 src/components/AsyncTypeahead/__snapshots__/typeaheadinput.spec.ts.snap create mode 100644 src/components/AsyncTypeahead/index.ts create mode 100644 src/components/AsyncTypeahead/typeaheadinput.spec.ts create mode 100644 src/components/AsyncTypeahead/types.ts create mode 100644 src/demo/pages/TypeaheadDemo.vue diff --git a/docs/components.md b/docs/components.md index 3396852..7f3f69a 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1,6 +1,7 @@ # Component Documentation +- [AsyncTypeahead](../src/components/AsyncTypeahead//README.md) - [Breadcrumbs](../src/components/Breadcrumbs//README.md) - [Button](../src/components/Button//README.md) - [Dropdown](../src/components/Dropdown//README.md) diff --git a/src/components/AsyncTypeahead/AsyncTypeahead.vue b/src/components/AsyncTypeahead/AsyncTypeahead.vue new file mode 100644 index 0000000..12a562a --- /dev/null +++ b/src/components/AsyncTypeahead/AsyncTypeahead.vue @@ -0,0 +1,264 @@ + + + + + + diff --git a/src/components/AsyncTypeahead/README.md b/src/components/AsyncTypeahead/README.md new file mode 100644 index 0000000..e0e76e6 --- /dev/null +++ b/src/components/AsyncTypeahead/README.md @@ -0,0 +1,13 @@ +# AsyncTypeahead + +This component accepts + +## Props + +| name | required? | types | description | default | +| ----------- | --------- | ---------------------------------------- | -------------------------- | --------- | +| `isLoading` | no | `boolean` | Request state | `false` | +| `intent` | yes | `"primary" \| "secondary" \| "tertiary"` | Determines style of button | `primary` | + +## Example + diff --git a/src/components/AsyncTypeahead/__snapshots__/typeaheadinput.spec.ts.snap b/src/components/AsyncTypeahead/__snapshots__/typeaheadinput.spec.ts.snap new file mode 100644 index 0000000..2e400df --- /dev/null +++ b/src/components/AsyncTypeahead/__snapshots__/typeaheadinput.spec.ts.snap @@ -0,0 +1,94 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TypeaheadInput > emits onBlur event 1`] = ` +
+ + + + +
+`; + +exports[`TypeaheadInput > emits onFocus event and clears input if no items 1`] = ` +
+ + + + +
+`; + +exports[`TypeaheadInput > emits onInput event 1`] = ` +
+ + + + +
+`; + +exports[`TypeaheadInput > focuses input on arrow up from first list item 1`] = ` +
+ + + + +
+`; + +exports[`TypeaheadInput > focuses next list item on arrow down 1`] = ` +
+ + + + +
+`; + +exports[`TypeaheadInput > focuses previous list item on arrow up 1`] = ` +
+ + + + +
+`; diff --git a/src/components/AsyncTypeahead/index.ts b/src/components/AsyncTypeahead/index.ts new file mode 100644 index 0000000..4bbc5c8 --- /dev/null +++ b/src/components/AsyncTypeahead/index.ts @@ -0,0 +1 @@ +export { default as AsyncTypeahead } from './AsyncTypeahead.vue'; diff --git a/src/components/AsyncTypeahead/typeaheadinput.spec.ts b/src/components/AsyncTypeahead/typeaheadinput.spec.ts new file mode 100644 index 0000000..5fb0f9d --- /dev/null +++ b/src/components/AsyncTypeahead/typeaheadinput.spec.ts @@ -0,0 +1,224 @@ +import TypeaheadInput from './AsyncTypeahead.vue'; +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { nextTick } from 'vue'; + +const MOCK_SEARCH_VALUE = 'lev'; +const MOCK_ITEMS = ['Levantine', 'Leviathan', 'Levi', 'Levant']; +// To do: update test to handle more complex items +// const MOCK_ITEMS = [ +// { +// county: 'Hockley', +// display_name: 'Levelland', +// locality: 'Levelland', +// state: 'Texas', +// type: 'Locality', +// }, +// { +// county: 'Franklin', +// display_name: 'Leverett', +// locality: 'Leverett', +// state: 'Massachusetts', +// type: 'Locality', +// }, +// { +// county: 'Levy', +// display_name: 'Levy', +// locality: null, +// state: 'Florida', +// type: 'County', +// }, +// { +// county: 'Marion', +// display_name: 'Belleview', +// locality: 'Belleview', +// state: 'Florida', +// type: 'Locality', +// }, +// ]; + +// Setup function to mount the component with optional props +const mountComponent = (props = { id: '' }) => { + return mount(TypeaheadInput, { + props: { + items: MOCK_ITEMS, + ...props, + }, + attachTo: document.body, + }); +}; + +// Function to get the input element +const getInput = (wrapper) => wrapper.find('[data-test="typeahead-input"]'); + +// Function to get the list items +const getListItems = (wrapper) => + wrapper.findAll('[data-test="typeahead-list-item"]'); + +// Function to set input value and wait for update +const setInputValue = async (input, value) => { + input.setValue(value); + await nextTick(); +}; + +// Function to focus input and set value +const focusAndSetInput = async (wrapper, value) => { + const input = getInput(wrapper); + await input.trigger('focus'); + await setInputValue(input, value); +}; + +// Test suite +describe('TypeaheadInput', () => { + it('emits onInput event', async () => { + const wrapper = mountComponent(); + const input = getInput(wrapper); + + await input.trigger('input'); + + expect(wrapper.html()).toMatchSnapshot(); + expect(wrapper.emitted().onInput).toBeTruthy(); + }); + + it('emits onFocus event and clears input if no items', async () => { + const wrapper = mountComponent(); + const input = getInput(wrapper); + await setInputValue(input, MOCK_SEARCH_VALUE); + + wrapper.setProps({ items: [] }); + await input.trigger('click'); + await input.trigger('focus'); + + await nextTick(); + + expect(wrapper.html()).toMatchSnapshot(); + expect(wrapper.emitted().onFocus).toBeTruthy(); + expect(input.text()).toBe(''); + }); + + it('emits onBlur event', async () => { + const wrapper = mountComponent(); + const input = getInput(wrapper); + + await input.trigger('blur'); + + expect(wrapper.html()).toMatchSnapshot(); + expect(wrapper.emitted().onBlur).toBeTruthy(); + }); + + it('focuses next list item on arrow down', async () => { + const wrapper = mountComponent(); + await focusAndSetInput(wrapper, MOCK_SEARCH_VALUE); + const [item1, item2] = getListItems(wrapper); + + await getInput(wrapper).trigger('click'); + await getInput(wrapper).trigger('keydown', { + key: 'ArrowDown', + keyCode: 40, + }); + + expect(document.activeElement).toBe(item1.element); + + await item1.trigger('keydown', { key: 'ArrowDown', keyCode: 40 }); + + expect(document.activeElement).toBe(item2.element); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('focuses previous list item on arrow up', async () => { + const wrapper = mountComponent(); + await focusAndSetInput(wrapper, MOCK_SEARCH_VALUE); + const [item1, item2] = getListItems(wrapper); + + await item2.trigger('focus'); + await item2.trigger('keydown', { key: 'ArrowUp', keyCode: 38 }); + + expect((document.activeElement as HTMLElement).innerText).toContain( + item1.element.textContent + ); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('focuses input on arrow up from first list item', async () => { + const wrapper = mountComponent(); + await focusAndSetInput(wrapper, MOCK_SEARCH_VALUE); + const [item1] = getListItems(wrapper); + + await item1.trigger('focus'); + await item1.trigger('keydown', { key: 'ArrowUp', keyCode: 38 }); + + expect(document.activeElement).toBe(getInput(wrapper).element); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('selects item and emits selectItem event on click', async () => { + const wrapper = mountComponent(); + await focusAndSetInput(wrapper, MOCK_SEARCH_VALUE); + const item1 = getListItems(wrapper)[0]; + + await item1.trigger('click'); + expect(wrapper.emitted().selectItem).toBeTruthy(); + // @ts-expect-error + expect(wrapper.emitted().selectItem[0][0]).toEqual(MOCK_ITEMS[0]); + }); + + it('selects item and emits selectItem event on enter', async () => { + const wrapper = mountComponent(); + await focusAndSetInput(wrapper, MOCK_SEARCH_VALUE); + const item1 = getListItems(wrapper)[0]; + + await item1.trigger('keydown', { key: 'Enter', keyCode: 13 }); + expect(wrapper.emitted().selectItem).toBeTruthy(); + // @ts-expect-error + expect(wrapper.emitted().selectItem[0][0]).toEqual(MOCK_ITEMS[0]); + }); + + // TODO: set up test as above for complex items + // it('formats text correctly for different item types', () => { + // const { vm } = mountComponent(); + + // const localityItem = { + // display_name: 'City', + // county: 'County', + // state: 'California', + // type: 'Locality', + // }; + // const countyItem = { + // display_name: 'County', + // state: 'California', + // type: 'County', + // }; + // const stateItem = { display_name: 'California', type: 'State' }; + + // expect(vm.formatText(localityItem)).toBe('City County CA'); + // expect(vm.formatText(countyItem)).toBe('County CA'); + // expect(vm.formatText(stateItem)).toBe('California'); + // }); + + it('bolds matched text correctly', () => { + const { vm } = mountComponent(); + // @ts-expect-error + vm.input = 'test'; + + // @ts-expect-error + expect(vm.boldMatchText('This is a test string')).toBe( + 'This is a test string' + ); + }); + + it('computes wrapperId correctly', () => { + const { vm } = mountComponent({ + id: 'test-id', + }); + + // @ts-expect-error + expect(vm.wrapperId).toBe('test-id_wrapper'); + }); + + it('computes itemsToDisplay correctly', () => { + const { vm } = mountComponent(); + + // @ts-expect-error + expect(vm.itemsToDisplay).toEqual(MOCK_ITEMS); + }); +}); diff --git a/src/components/AsyncTypeahead/types.ts b/src/components/AsyncTypeahead/types.ts new file mode 100644 index 0000000..22b1256 --- /dev/null +++ b/src/components/AsyncTypeahead/types.ts @@ -0,0 +1,7 @@ +export interface PdapAsyncTypeaheadProps { + id: string; + placeholder?: string; + items?: T[]; + formatItemForDisplay?: (item: T) => string; + error?: string; +} diff --git a/src/components/index.ts b/src/components/index.ts index d4290c6..0946845 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -20,3 +20,4 @@ export { Dropdown } from './Dropdown'; export { Breadcrumbs } from './Breadcrumbs'; export { Spinner } from './Spinner'; export { RecordTypeIcon } from './RecordTypeIcon'; +export { AsyncTypeahead } from './AsyncTypeahead'; diff --git a/src/demo/pages/TypeaheadDemo.vue b/src/demo/pages/TypeaheadDemo.vue new file mode 100644 index 0000000..17ec2b8 --- /dev/null +++ b/src/demo/pages/TypeaheadDemo.vue @@ -0,0 +1,116 @@ + + + diff --git a/src/demo/router.js b/src/demo/router.js index 89fd514..1d96f64 100644 --- a/src/demo/router.js +++ b/src/demo/router.js @@ -1,6 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import ComponentDemo from './pages/ComponentDemo.vue'; import SignupFormDemo from './pages/SignupFormDemo.vue'; +import TypeaheadDemo from './pages/TypeaheadDemo.vue'; import FormV2Demo from './pages/FormV2Demo.vue'; const routes = [ @@ -52,6 +53,11 @@ const routes = [ component: FormV2Demo, name: 'FormV2 Demo', }, + { + path: '/typeahead-demo', + component: TypeaheadDemo, + name: 'FormV2 Demo', + }, ]; const router = createRouter({ diff --git a/src/index.ts b/src/index.ts index 071a061..87c477b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export * from './components'; import './styles/styles.css'; // Types +export * from './components/AsyncTypeahead/types'; export * from './components/Button/types'; export * from './components/Dropdown/types'; export * from './components/ErrorBoundary/types';