fix(sidebar): keep tooltips within the viewport

This commit is contained in:
zipg 2026-07-30 18:15:41 +08:00 committed by GitHub
parent 661575227f
commit 7d1a72e074
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 76 additions and 3 deletions

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { floatingArrowOffset, floatingViewportShift } from "@/lib/common/floatingViewportPosition";
const props = withDefaults(
defineProps<{
@ -30,6 +31,7 @@ const tooltipRef = ref<HTMLElement>();
const show = ref(false);
const x = ref(0);
const y = ref(0);
const arrowOffset = ref<number>();
let timer: ReturnType<typeof setTimeout> | null = null;
let closeTimer: ReturnType<typeof setTimeout> | null = null;
let suppressOpenUntil = 0;
@ -79,6 +81,11 @@ const tooltipSurfaceClass = computed(() => (props.surface === "popover" ? "bg-po
const arrowSurfaceClass = computed(() => (props.surface === "popover" ? "bg-popover border-border" : "bg-foreground border-foreground"));
const arrowStyle = computed(() => {
if (arrowOffset.value === undefined) return undefined;
return props.side === "left" || props.side === "right" ? { top: `${arrowOffset.value}px` } : { left: `${arrowOffset.value}px` };
});
function clearTimer() {
if (!timer) return;
clearTimeout(timer);
@ -119,6 +126,7 @@ function isDisabled(): boolean {
function updatePosition() {
const el = triggerElement();
if (!el) return;
arrowOffset.value = undefined;
const rect = el.getBoundingClientRect();
const offset = props.sideOffset;
switch (props.side) {
@ -142,6 +150,16 @@ function updatePosition() {
}
}
function fitPositionToViewport() {
const tooltip = tooltipRef.value;
if (!tooltip) return;
const rect = tooltip.getBoundingClientRect();
const shift = floatingViewportShift(rect, { width: window.innerWidth, height: window.innerHeight });
x.value += shift.x;
y.value += shift.y;
arrowOffset.value = props.side === "left" || props.side === "right" ? floatingArrowOffset(rect.height, shift.y) : floatingArrowOffset(rect.width, shift.x);
}
function close() {
clearTimer();
clearCloseTimer();
@ -210,6 +228,7 @@ function open(source: "hover" | "focus") {
updatePosition();
openSource = source;
show.value = true;
void nextTick(fitPositionToViewport);
addGlobalListeners();
}
@ -234,7 +253,10 @@ watch(
() => [props.disabled, props.text] as const,
() => {
if (isDisabled() || !props.text) close();
else if (show.value) updatePosition();
else if (show.value) {
updatePosition();
void nextTick(fitPositionToViewport);
}
},
);
</script>
@ -255,7 +277,7 @@ watch(
@mouseleave="scheduleClose"
>
<slot name="content">{{ text }}</slot>
<span :class="[arrowClass, arrowSurfaceClass, 'size-2.5 rotate-45 rounded-[2px]']" aria-hidden="true" />
<span :class="[arrowClass, arrowSurfaceClass, 'size-2.5 rotate-45 rounded-[2px]']" :style="arrowStyle" aria-hidden="true" />
</div>
</Teleport>
</template>

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { floatingArrowOffset, floatingViewportShift } from "@/lib/common/floatingViewportPosition";
describe("floating viewport position", () => {
it("moves a floating element inside each viewport edge", () => {
expect(floatingViewportShift({ left: -24, right: 296, top: 40, bottom: 80 }, { width: 800, height: 600 })).toEqual({ x: 32, y: 0 });
expect(floatingViewportShift({ left: 600, right: 840, top: 40, bottom: 80 }, { width: 800, height: 600 })).toEqual({ x: -48, y: 0 });
expect(floatingViewportShift({ left: 40, right: 240, top: -12, bottom: 28 }, { width: 800, height: 600 })).toEqual({ x: 0, y: 20 });
expect(floatingViewportShift({ left: 40, right: 240, top: 570, bottom: 620 }, { width: 800, height: 600 })).toEqual({ x: 0, y: -28 });
});
it("does not move an element that already fits", () => {
expect(floatingViewportShift({ left: 8, right: 792, top: 8, bottom: 592 }, { width: 800, height: 600 })).toEqual({ x: 0, y: 0 });
});
it("keeps the arrow pointing at the trigger after shifting the floating element", () => {
expect(floatingArrowOffset(320, 32)).toBe(128);
expect(floatingArrowOffset(320, -48)).toBe(208);
expect(floatingArrowOffset(40, 100)).toBe(8);
expect(floatingArrowOffset(40, -100)).toBe(32);
});
});

View File

@ -0,0 +1,29 @@
export interface FloatingRect {
left: number;
right: number;
top: number;
bottom: number;
}
export interface FloatingViewport {
width: number;
height: number;
}
export function floatingViewportShift(rect: FloatingRect, viewport: FloatingViewport, margin = 8): { x: number; y: number } {
let x = 0;
let y = 0;
if (rect.left < margin) x = margin - rect.left;
else if (rect.right > viewport.width - margin) x = viewport.width - margin - rect.right;
if (rect.top < margin) y = margin - rect.top;
else if (rect.bottom > viewport.height - margin) y = viewport.height - margin - rect.bottom;
return { x, y };
}
export function floatingArrowOffset(length: number, shift: number, padding = 8): number {
const maximum = Math.max(padding, length - padding);
return Math.min(Math.max(padding, length / 2 - shift), maximum);
}