fix(sidebar): keep tooltips within the viewport
This commit is contained in:
parent
661575227f
commit
7d1a72e074
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue