-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(components): async typeahead dropdown component
adds typeahead and associated tests resolves #133
- Loading branch information
1 parent
3bf5dc1
commit 470e406
Showing
11 changed files
with
728 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
94 changes: 94 additions & 0 deletions
94
src/components/AsyncTypeahead/__snapshots__/typeaheadinput.spec.ts.snap
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default as AsyncTypeahead } from './AsyncTypeahead.vue'; |
Oops, something went wrong.