Skip to content

Commit

Permalink
feat(components): async typeahead dropdown component
Browse files Browse the repository at this point in the history
adds typeahead and associated tests

resolves #133
  • Loading branch information
joshuagraber committed Jan 28, 2025
1 parent 3bf5dc1 commit 470e406
Show file tree
Hide file tree
Showing 11 changed files with 728 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/components.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Component Documentation
<!-- This file is auto-generated from README.md files contained in the components directory, no need to edit this file directly. -->

- [AsyncTypeahead](../src/components/AsyncTypeahead//README.md)
- [Breadcrumbs](../src/components/Breadcrumbs//README.md)
- [Button](../src/components/Button//README.md)
- [Dropdown](../src/components/Dropdown//README.md)
Expand Down
264 changes: 264 additions & 0 deletions src/components/AsyncTypeahead/AsyncTypeahead.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
<!-- TODO: Once this component is sufficiently generic, move to design-system? -->
<template>
<div
:id="wrapperId"
data-test="typeahead-wrapper"
class="pdap-typeahead"
:class="{ 'pdap-typeahead-expanded': isListOpen }"
>
<label v-if="$slots.label" class="col-span-2" :for="id">
<slot name="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>

<input
:id="id"
ref="inputRef"
v-model="input"
data-test="typeahead-input"
class="pdap-typeahead-input"
type="text"
:placeholder="placeholder"
autocomplete="off"
v-bind="$attrs"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@keydown.down.prevent="onArrowDown"
/>
<ul
v-if="itemsToDisplay?.length && inputRef?.value"
data-test="typeahead-list"
class="pdap-typeahead-list"
>
<li
v-for="(item, index) in itemsToDisplay"
:key="index"
class="pdap-typeahead-list-item"
data-test="typeahead-list-item"
role="button"
tabindex="0"
@click="() => selectItem(item)"
@keydown.enter.prevent="selectItem(item)"
@keydown.down.prevent="onArrowDown"
@keydown.up.prevent="onArrowUp"
>
<slot v-if="$slots.item" name="item" v-bind="item" />
<span v-else>{{ boldMatchText(formatItemForDisplay(item)) }}</span>
</li>
</ul>
<ul
v-else-if="typeof itemsToDisplay === 'undefined' && input.length > 1"
class="pdap-typeahead-list"
data-test="typeahead-list-not-found"
>
<li class="max-w-[unset]">
<slot
v-if="$slots['not-found']"
name="not-found"
v-bind="$slots['not-found'] ?? {}"
/>
<span v-else>
<strong>No results found.</strong>
Please check your spelling.
</span>
</li>
</ul>
</div>
</template>

<script setup lang="ts" generic="T">
import {
ref,
computed,
onMounted,
onUnmounted,
watch,
onBeforeUpdate,
} from 'vue';
import { PdapAsyncTypeaheadProps } from './types';

/* Props and emits */
const props = withDefaults(defineProps<PdapAsyncTypeaheadProps<T>>(), {
placeholder: '',
formatItemForDisplay: (item: T) => JSON.stringify(item),
});
const emit = defineEmits(['onInput', 'onFocus', 'onBlur', 'selectItem']);

/* Refs and reactive vars */
const inputRef = ref();
const input = ref('');

/* Computed vars */
const wrapperId = computed(() => `${props.id}_wrapper`);
const itemsToDisplay = computed(() => props.items);
const isListOpen = computed(
() =>
(itemsToDisplay.value?.length && inputRef?.value?.value) ||
(typeof itemsToDisplay.value === 'undefined' && input.value.length > 1)
);

/* Lifecycle methods and listeners */
onMounted(() => {
window.addEventListener('resize', setInputPositionForList);
});

onBeforeUpdate(setInputPositionForList);

onUnmounted(() => {
window.removeEventListener('resize', setInputPositionForList);
});

/* Watch expressions */
watch(
() => inputRef.value,
(ref) => {
if (ref) setInputPositionForList();
}
);

/* Methods */
function setInputPositionForList() {
document.documentElement.style.setProperty(
'--typeaheadBottom',
inputRef.value.offsetTop + inputRef.value.offsetHeight + 'px'
);
document.documentElement.style.setProperty(
'--typeaheadListWidth',
inputRef.value.offsetWidth + 'px'
);
}
function onInput(e: Event) {
emit('onInput', e);
}
function onFocus(e?: Event) {
if (Array.isArray(itemsToDisplay.value) && !itemsToDisplay.value.length) {
clearInput();
emit('selectItem', undefined);
}

if (e) emit('onFocus', e);
}
function onBlur(e: Event) {
emit('onBlur', e);
}

function onArrowDown() {
const items = Array.from(
document.getElementsByClassName('pdap-typeahead-list-item')
) as HTMLElement[];

const focusedIndex = items.indexOf(document.activeElement as HTMLElement); // Casting is okay here because we don't need to access any of the missing properties.

if (focusedIndex === items.length - 1) return;

if (focusedIndex === -1) {
items[0].focus();
} else {
items[focusedIndex + 1].focus();
}
}

function onArrowUp() {
const items = Array.from(
document.getElementsByClassName('pdap-typeahead-list-item')
) as HTMLElement[];

const focusedIndex = items.indexOf(document.activeElement as HTMLElement);

if (focusedIndex === 0) {
inputRef.value.focus();
} else {
items[focusedIndex - 1].focus();
}
}

function selectItem(item: T) {
input.value = props.formatItemForDisplay
? props.formatItemForDisplay(item)
: String(item);

inputRef.value.blur();
emit('selectItem', item);
}

function escapeRegExp(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function boldMatchText(text: string) {
const regexp = new RegExp(`(${escapeRegExp(input.value)})`, 'ig');
return text.replace(regexp, '<strong>$1</strong>');
}
function clearInput() {
input.value = '';
}
// function getInput() {
// return inputRef.value;
// }
function focusInput() {
inputRef.value.focus();
onFocus();
}
// function blurInput() {
// inputRef.value.blur();
// onBlur();
// }

defineExpose({
boldMatchText,
clearInput,
focusInput,
get value() {
return input.value;
},
});
</script>

<style>
.pdap-typeahead {
@apply leading-normal w-full flex flex-col;
}

.pdap-typeahead label {
@apply max-w-[max-content] text-lg py-1 font-medium;
}

.pdap-typeahead-input {
@apply rounded-md;
}

.pdap-typeahead-input,
.pdap-typeahead-list {
@apply bg-neutral-50 border-2 border-neutral-500 border-solid p-2 text-neutral-950;
}

.pdap-typeahead-input::placeholder {
@apply text-neutral-600 text-lg;
}

.pdap-typeahead-input:focus,
.pdap-typeahead-input:focus-within,
.pdap-typeahead-input:focus-visible {
@apply border-2 border-brand-gold border-solid outline-none;
}

.pdap-typeahead-list {
@apply absolute w-[var(--typeaheadListWidth)] top-[var(--typeaheadBottom)] z-50 overflow-scroll;
}

.pdap-typeahead-list-item {
@apply mt-1 max-w-[unset] p-2 flex items-center gap-6 text-sm md:text-lg;
}

.pdap-typeahead-list-item:focus,
.pdap-typeahead-list-item:focus-visible,
.pdap-typeahead-list-item:focus-within,
.pdap-typeahead-list-item:hover {
@apply outline-none text-neutral-950 bg-neutral-300;
}
</style>
13 changes: 13 additions & 0 deletions src/components/AsyncTypeahead/README.md
Original file line number Diff line number Diff line change
@@ -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

Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`TypeaheadInput > emits onBlur event 1`] = `
<div class="pdap-typeahead" id="_wrapper">
<!--v-if-->
<!--v-if-->
<input autocomplete="off" class="pdap-typeahead-input" id placeholder type="text">
<!--v-if-->
</div>
`;
exports[`TypeaheadInput > emits onFocus event and clears input if no items 1`] = `
<div class="pdap-typeahead" id="_wrapper">
<!--v-if-->
<!--v-if-->
<input autocomplete="off" class="pdap-typeahead-input" id placeholder type="text">
<!--v-if-->
</div>
`;
exports[`TypeaheadInput > emits onInput event 1`] = `
<div class="pdap-typeahead" id="_wrapper">
<!--v-if-->
<!--v-if-->
<input autocomplete="off" class="pdap-typeahead-input" id placeholder type="text">
<!--v-if-->
</div>
`;
exports[`TypeaheadInput > focuses input on arrow up from first list item 1`] = `
<div class="pdap-typeahead" id="_wrapper">
<!--v-if-->
<!--v-if-->
<input autocomplete="off" class="pdap-typeahead-input" id placeholder type="text">
<ul class="pdap-typeahead-list">
<li class="pdap-typeahead-list-item" role="button" tabindex="0">
<span>"<strong>Lev</strong>antine"</span>
</li>
<li class="pdap-typeahead-list-item" role="button" tabindex="0">
<span>"<strong>Lev</strong>iathan"</span>
</li>
<li class="pdap-typeahead-list-item" role="button" tabindex="0">
<span>"<strong>Lev</strong>i"</span>
</li>
<li class="pdap-typeahead-list-item" role="button" tabindex="0">
<span>"<strong>Lev</strong>ant"</span>
</li>
</ul>
</div>
`;
exports[`TypeaheadInput > focuses next list item on arrow down 1`] = `
<div class="pdap-typeahead" id="_wrapper">
<!--v-if-->
<!--v-if-->
<input autocomplete="off" class="pdap-typeahead-input" id placeholder type="text">
<ul class="pdap-typeahead-list">
<li class="pdap-typeahead-list-item" role="button" tabindex="0">
<span>"<strong>Lev</strong>antine"</span>
</li>
<li class="pdap-typeahead-list-item" role="button" tabindex="0">
<span>"<strong>Lev</strong>iathan"</span>
</li>
<li class="pdap-typeahead-list-item" role="button" tabindex="0">
<span>"<strong>Lev</strong>i"</span>
</li>
<li class="pdap-typeahead-list-item" role="button" tabindex="0">
<span>"<strong>Lev</strong>ant"</span>
</li>
</ul>
</div>
`;
exports[`TypeaheadInput > focuses previous list item on arrow up 1`] = `
<div class="pdap-typeahead" id="_wrapper">
<!--v-if-->
<!--v-if-->
<input autocomplete="off" class="pdap-typeahead-input" id placeholder type="text">
<ul class="pdap-typeahead-list">
<li class="pdap-typeahead-list-item" role="button" tabindex="0">
<span>"<strong>Lev</strong>antine"</span>
</li>
<li class="pdap-typeahead-list-item" role="button" tabindex="0">
<span>"<strong>Lev</strong>iathan"</span>
</li>
<li class="pdap-typeahead-list-item" role="button" tabindex="0">
<span>"<strong>Lev</strong>i"</span>
</li>
<li class="pdap-typeahead-list-item" role="button" tabindex="0">
<span>"<strong>Lev</strong>ant"</span>
</li>
</ul>
</div>
`;
1 change: 1 addition & 0 deletions src/components/AsyncTypeahead/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as AsyncTypeahead } from './AsyncTypeahead.vue';
Loading

0 comments on commit 470e406

Please # to comment.