Skip to content

Commit

Permalink
feat: search in select and scroll to top
Browse files Browse the repository at this point in the history
  • Loading branch information
colinlienard committed May 10, 2024
1 parent b9565a9 commit 1643f5f
Show file tree
Hide file tree
Showing 4 changed files with 83 additions and 11 deletions.
4 changes: 4 additions & 0 deletions components/Input.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
<script setup lang="ts">
const model = defineModel<string>();
const { placeholder } = defineProps<{ placeholder: string }>();
const inputRef = ref<HTMLInputElement>();
defineExpose({ focus: () => inputRef.value?.focus() });
</script>

<template>
<label class="box input cursor-text hover:border-slate-400">
<slot />
<input
ref="inputRef"
v-model="model"
class="bg-transparent outline-none placeholder:text-slate-400"
type="text"
Expand Down
26 changes: 22 additions & 4 deletions components/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,24 @@ const { options, placeholder } = defineProps<{ options: string[]; placeholder: s
const element = ref<HTMLElement>();
const isOpen = ref(false);
const position = ref({ x: 0, y: 0, width: 0 });
const search = ref('');
const searchInputRef = ref<HTMLInputElement>();
watch(isOpen, () => {
if (!isOpen.value) return;
const { offsetTop, offsetLeft: x, offsetWidth: width, offsetHeight } = element.value!;
const y = offsetTop + offsetHeight + 8;
position.value = { x, y, width };
search.value = '';
});
watchEffect(() => {
searchInputRef.value?.focus();
});
const filteredOptions = computed(() => {
if (!search.value) return options;
return options.filter((option) => option.toLowerCase().includes(search.value.toLowerCase()));
});
function onClickOutside(event: MouseEvent) {
Expand Down Expand Up @@ -40,8 +52,8 @@ function onKeydown(event: KeyboardEvent) {
if (document.activeElement?.getAttribute('data-select-option')) {
const element =
event.key === 'ArrowDown'
? document.activeElement.nextSibling
: document.activeElement.previousSibling;
? document.activeElement.nextElementSibling
: document.activeElement.previousElementSibling;
(element as HTMLElement)?.focus();
} else {
(document.querySelector('[data-select-option]') as HTMLElement | undefined)?.focus();
Expand Down Expand Up @@ -93,11 +105,17 @@ onUnmounted(() => {
<div
v-if="isOpen"
data-select
class="absolute left-0 right-0 top-full z-50 flex max-h-40 flex-col overflow-auto rounded-lg border border-solid border-slate-300 bg-slate-50 shadow-sm"
class="absolute left-0 right-0 top-full z-50 flex max-h-56 flex-col overflow-auto rounded-lg border border-solid border-slate-300 bg-slate-50 shadow-sm"
:style="{ top: `${position.y}px`, left: `${position.x}px`, width: `${position.width}px` }"
>
<Input
ref="searchInputRef"
v-model="search"
placeholder="Search a language"
class="m-2"
/>
<button
v-for="option in options"
v-for="option in filteredOptions"
:key="option"
class="box group border-none !-outline-offset-4"
:data-select-option="option"
Expand Down
60 changes: 54 additions & 6 deletions pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { StarIcon } from 'heroicons';
import { ArrowUpIcon, StarIcon } from 'heroicons';
const { data } = await useFetch('/api/repositories');
const settings = useSettings();
const languages = useLanguages();
const topOfTableRef = ref<HTMLElement>();
const hasScrolled = ref(false);
const repositories = computed(() => {
if (!settings.search && !settings.languages.length) return data.value;
Expand All @@ -31,6 +33,10 @@ function onHoverEffectMouseEnter(event: MouseEvent) {
hoverEffect.value = { top: target.offsetTop, height, opacity: 1 };
}
function onClickScrollUp() {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
watchEffect(() => {
if (!data.value) return;
languages.value = data.value
Expand All @@ -42,14 +48,32 @@ watchEffect(() => {
}, [])
.sort();
});
let observer: IntersectionObserver | undefined;
watchEffect(() => {
if (!topOfTableRef.value) return;
if (!observer) {
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
hasScrolled.value = !entry.isIntersecting;
});
});
}
observer?.observe(topOfTableRef.value);
});
onUnmounted(() => {
observer?.disconnect();
});
</script>

<template>
<section class="table w-[64rem] table-fixed">
<div
class="after:content-[' '] sticky top-0 z-10 table-header-group bg-slate-50 after:absolute after:inset-x-0 after:-bottom-[1px] after:h-[1px] after:bg-slate-300"
:class="`sticky top-0 z-10 table-header-group border-solid after:absolute after:inset-0 after:border-b after:border-slate-300 after:transition-shadow after:content-[''] ${hasScrolled && 'after:shadow-[0_1rem_1rem_-1.5rem_#94a3b8]'}`"
>
<div class="table-row text-slate-400 *:table-cell *:px-4 *:py-6">
<div class="relative table-row bg-slate-50 text-slate-400 *:table-cell *:px-4 *:py-6">
<div class="w-[8%]">Rank</div>
<div class="w-[30%]">Name</div>
<div class="w-[12%]">Stars</div>
Expand All @@ -58,6 +82,7 @@ watchEffect(() => {
<div class="w-[8%]">Age</div>
</div>
</div>
<div ref="topOfTableRef" />
<div class="relative table-row-group" @mouseleave="hoverEffect.opacity = 0">
<div
class="absolute h-10 w-full bg-white transition-all"
Expand All @@ -68,18 +93,18 @@ watchEffect(() => {
}"
/>
<NuxtLink
v-for="(repo, index) in repositories"
v-for="repo in repositories"
:key="repo.name"
:to="repo.url"
target="_blank"
class="after:content-[' '] relative table-row cursor-alias *:table-cell *:px-4 *:py-6 *:align-top after:absolute after:inset-0 after:h-[1px] after:bg-slate-300"
class="relative table-row cursor-alias *:table-cell *:px-4 *:py-6 *:align-top after:absolute after:inset-0 after:h-[1px] after:content-[''] [&:not(:first-of-type):after]:bg-slate-300"
@mouseenter="onHoverEffectMouseEnter"
>
<div>
<div
class="flex h-8 w-8 -translate-y-1 items-center justify-center rounded-full border border-solid border-slate-300"
>
{{ index + 1 }}
{{ repo.rank }}
</div>
</div>
<div>
Expand Down Expand Up @@ -112,5 +137,28 @@ watchEffect(() => {
<div>{{ formatDuration(repo.age) }}</div>
</NuxtLink>
</div>
<Teleport to="body">
<Transition>
<button
v-if="hasScrolled"
class="fixed bottom-4 right-4 rounded-full bg-slate-50 p-4"
@click="onClickScrollUp"
>
<ArrowUpIcon class="h-4" />
</button>
</Transition>
</Teleport>
</section>
</template>

<style scoped>
.v-enter-active,
.v-leave-active {
transition: opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>
4 changes: 3 additions & 1 deletion server/api/repositories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
type Repository = {
rank: number;
name: string;
ownerName: string;
image: string;
Expand Down Expand Up @@ -52,7 +53,8 @@ export default defineEventHandler(async (event) => {
});
const { data } = await response.json();

const result: Repository[] = data.search.edges.map(({ node }: any) => ({
const result: Repository[] = data.search.edges.map(({ node }: any, index: number) => ({
rank: index + 1,
name: node.name,
ownerName: node.owner.login,
image: node.owner.avatarUrl,
Expand Down

0 comments on commit 1643f5f

Please sign in to comment.