bug fixes, and performance improvements, and drawer
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import type { SearchSource } from "~/utils/tool-rendering";
|
||||
import { autoUpdate, flip, offset, shift, useFloating } from "@floating-ui/vue";
|
||||
import { computed, onBeforeUnmount, ref, useId } from "vue";
|
||||
import { getFaviconUrl } from "~/utils/tool-rendering";
|
||||
|
||||
const props = defineProps<{
|
||||
ids: string[];
|
||||
sources?: ReadonlyMap<string, SearchSource>;
|
||||
}>();
|
||||
|
||||
const entries = computed(() => [...new Set(props.ids)].map(id => ({ id, source: props.sources?.get(id) })));
|
||||
const firstSource = computed(() => entries.value.find(entry => entry.source)?.source);
|
||||
const label = computed(() => {
|
||||
const first = firstSource.value?.hostname ?? "Source unavailable";
|
||||
return entries.value.length > 1 ? `${first} +${entries.value.length - 1}` : first;
|
||||
});
|
||||
const popupId = useId();
|
||||
const trigger = ref<HTMLElement | null>(null);
|
||||
const preview = ref<HTMLElement | null>(null);
|
||||
const open = ref(false);
|
||||
const failedFavicons = ref(new Set<string>());
|
||||
const { floatingStyles, placement } = useFloating(trigger, preview, {
|
||||
open,
|
||||
strategy: "fixed",
|
||||
placement: "top-start",
|
||||
middleware: [offset(8), flip(), shift({ padding: 12 })],
|
||||
whileElementsMounted: autoUpdate,
|
||||
transform: false,
|
||||
});
|
||||
|
||||
const transformOrigin = computed(() =>
|
||||
placement.value.startsWith("top")
|
||||
? "transform-origin-bottom-center"
|
||||
: "transform-origin-top-center"
|
||||
);
|
||||
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pointerInside = false;
|
||||
|
||||
const cancelClose = () => {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = undefined;
|
||||
};
|
||||
|
||||
const showPreview = () => {
|
||||
cancelClose();
|
||||
open.value = true;
|
||||
};
|
||||
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
// Allow the pointer to cross the gap between the badge and the preview.
|
||||
closeTimer = setTimeout(() => {
|
||||
closeTimer = undefined;
|
||||
const focused = document.activeElement;
|
||||
if (!pointerInside && !trigger.value?.contains(focused) && !preview.value?.contains(focused)) {
|
||||
open.value = false;
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const onPointerEnter = (event: PointerEvent) => {
|
||||
if (event.pointerType === "touch") {
|
||||
return;
|
||||
}
|
||||
pointerInside = true;
|
||||
showPreview();
|
||||
};
|
||||
|
||||
const onPointerLeave = (event: PointerEvent) => {
|
||||
if (event.pointerType === "touch") {
|
||||
return;
|
||||
}
|
||||
pointerInside = false;
|
||||
scheduleClose();
|
||||
};
|
||||
|
||||
const dismissPreview = () => {
|
||||
cancelClose();
|
||||
if (preview.value?.contains(document.activeElement)) {
|
||||
trigger.value?.focus();
|
||||
}
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
onBeforeUnmount(cancelClose);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a ref="trigger" :href="firstSource?.url" target="_blank" rel="noopener noreferrer"
|
||||
:tabindex="firstSource ? undefined : 0" :aria-expanded="open" :aria-controls="open ? popupId : undefined"
|
||||
@pointerenter="onPointerEnter" @pointerleave="onPointerLeave" @focusin="showPreview" @focusout="scheduleClose"
|
||||
@keydown.esc.stop.prevent="dismissPreview"
|
||||
class="inline-flex items-center gap-1 max-w-64 mx-1 px-2 border border-[var(--color-border)] rounded-full bg-[var(--bg-container)] hover:bg-[var(--color-hover)] transition-colors text-[var(--text-secondary)] text-xs align-baseline cursor-pointer focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--text-secondary)] focus-visible:outline-offset-2 decoration-none">
|
||||
<img v-if="firstSource && !failedFavicons.has(firstSource.hostname)" :src="getFaviconUrl(firstSource.hostname)"
|
||||
alt="" loading="lazy" class="rounded-[4px] w-[0.9rem] h-[0.9rem] m-0"
|
||||
@error="failedFavicons.add(firstSource.hostname)">
|
||||
{{ label }}
|
||||
</a>
|
||||
<Transition enter-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 translate-y-1">
|
||||
<span v-if="open" :id="popupId" ref="preview" @pointerenter="onPointerEnter" @pointerleave="onPointerLeave"
|
||||
@focusin="showPreview" @focusout="scheduleClose" @keydown.esc.stop.prevent="dismissPreview"
|
||||
class="z-2 inset-auto m-0 p-[0.85rem] w-[min(22rem,calc(100vw_-_24px))] max-h-[min(24rem,70vh)] overflow-y-auto border border-[var(--color-border)] rounded-xl bg-[var(--bg-surface)] text-[var(--text-primary)] shadow-md text-sm leading-normal"
|
||||
:class="transformOrigin" :style="floatingStyles">
|
||||
<span v-for="(entry, index) in entries" :key="entry.id"
|
||||
class="flex flex-col gap-[0.4rem] [overflow-wrap:anywhere]"
|
||||
:class="{ 'mt-[0.8rem] pt-[0.8rem] border-t border-[var(--color-border)]': index > 0 }">
|
||||
<template v-if="entry.source">
|
||||
<span class="text-xs text-[var(--text-secondary)]">{{ entry.source.hostname }} · Search
|
||||
excerpt</span>
|
||||
<a :href="entry.source.url" target="_blank" rel="noopener noreferrer">{{ entry.source.title }}</a>
|
||||
<span v-if="entry.source.snippet" class="whitespace-pre-wrap text-[var(--text-secondary)]">{{
|
||||
entry.source.snippet }}</span>
|
||||
</template>
|
||||
<span v-else>Source unavailable ({{ entry.id }})</span>
|
||||
</span>
|
||||
</span>
|
||||
</Transition>
|
||||
</template>
|
||||
Reference in New Issue
Block a user