Files
backspace/packages/web/src/hooks/useFloatingPosition.ts
T
Jannis Braun 37f199e544 feat: floating glass navigation and action bars in modals
Replace inline Save/Discard/Reset buttons with sticky glass-bubble pills
that float at the bottom of scrollable modal content. Make SpaceSettings
tab sidebar sticky with glass material. Convert RoleEditView "Back to
roles" into a sticky glass pill at the top. UserSettings Log Out + Save
always visible in a glass pill with separator. Also includes floating
position hook and popover/tooltip improvements from prior work.
2026-03-09 01:31:35 +01:00

167 lines
5.0 KiB
TypeScript

import { type RefObject, type CSSProperties, useState, useLayoutEffect, useCallback } from 'react';
type Placement = 'top' | 'bottom' | 'left' | 'right';
interface UseFloatingPositionOptions {
placement: Placement;
offset?: number;
enabled?: boolean;
}
interface UseFloatingPositionResult {
style: CSSProperties;
actualPlacement: Placement;
}
const VIEWPORT_PADDING = 8;
const oppositePlacement: Record<Placement, Placement> = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left',
};
function computePosition(
anchorRect: DOMRect,
floatingWidth: number,
floatingHeight: number,
placement: Placement,
offset: number,
): { top: number; left: number; actualPlacement: Placement } {
const vw = window.innerWidth;
const vh = window.innerHeight;
let top = 0;
let left = 0;
let actual = placement;
// Compute initial position on primary axis
if (placement === 'top') {
top = anchorRect.top - floatingHeight - offset;
left = anchorRect.left + anchorRect.width / 2 - floatingWidth / 2;
} else if (placement === 'bottom') {
top = anchorRect.bottom + offset;
left = anchorRect.left + anchorRect.width / 2 - floatingWidth / 2;
} else if (placement === 'left') {
top = anchorRect.top + anchorRect.height / 2 - floatingHeight / 2;
left = anchorRect.left - floatingWidth - offset;
} else {
top = anchorRect.top + anchorRect.height / 2 - floatingHeight / 2;
left = anchorRect.right + offset;
}
// Flip: if overflowing on primary axis, try the opposite side
if (placement === 'top' && top < VIEWPORT_PADDING) {
const flippedTop = anchorRect.bottom + offset;
if (flippedTop + floatingHeight <= vh - VIEWPORT_PADDING) {
top = flippedTop;
actual = 'bottom';
}
} else if (placement === 'bottom' && top + floatingHeight > vh - VIEWPORT_PADDING) {
const flippedTop = anchorRect.top - floatingHeight - offset;
if (flippedTop >= VIEWPORT_PADDING) {
top = flippedTop;
actual = 'top';
}
} else if (placement === 'left' && left < VIEWPORT_PADDING) {
const flippedLeft = anchorRect.right + offset;
if (flippedLeft + floatingWidth <= vw - VIEWPORT_PADDING) {
left = flippedLeft;
actual = 'right';
}
} else if (placement === 'right' && left + floatingWidth > vw - VIEWPORT_PADDING) {
const flippedLeft = anchorRect.left - floatingWidth - offset;
if (flippedLeft >= VIEWPORT_PADDING) {
left = flippedLeft;
actual = 'left';
}
}
// Clamp: keep within viewport on cross axis
if (actual === 'top' || actual === 'bottom') {
left = Math.max(VIEWPORT_PADDING, Math.min(left, vw - floatingWidth - VIEWPORT_PADDING));
} else {
top = Math.max(VIEWPORT_PADDING, Math.min(top, vh - floatingHeight - VIEWPORT_PADDING));
}
// Also clamp primary axis as last resort
top = Math.max(VIEWPORT_PADDING, Math.min(top, vh - floatingHeight - VIEWPORT_PADDING));
left = Math.max(VIEWPORT_PADDING, Math.min(left, vw - floatingWidth - VIEWPORT_PADDING));
return { top, left, actualPlacement: actual };
}
export function useFloatingPosition(
anchorRef: RefObject<HTMLElement | null>,
floatingRef: RefObject<HTMLElement | null>,
options: UseFloatingPositionOptions,
): UseFloatingPositionResult {
const { placement, offset = 8, enabled = true } = options;
const [result, setResult] = useState<{ top: number; left: number; actualPlacement: Placement }>({
top: -9999,
left: -9999,
actualPlacement: placement,
});
const update = useCallback(() => {
const anchor = anchorRef.current;
const floating = floatingRef.current;
if (!anchor || !floating) return;
const anchorRect = anchor.getBoundingClientRect();
const floatingRect = floating.getBoundingClientRect();
const pos = computePosition(
anchorRect,
floatingRect.width,
floatingRect.height,
placement,
offset,
);
setResult((prev) => {
if (prev.top === pos.top && prev.left === pos.left && prev.actualPlacement === pos.actualPlacement) {
return prev;
}
return pos;
});
}, [anchorRef, floatingRef, placement, offset]);
useLayoutEffect(() => {
if (!enabled) return;
update();
// Observe resize of both elements
const anchor = anchorRef.current;
const floating = floatingRef.current;
const targets: Element[] = [];
if (anchor) targets.push(anchor);
if (floating) targets.push(floating);
const ro = new ResizeObserver(update);
targets.forEach((t) => ro.observe(t));
// Also update on scroll/resize
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
ro.disconnect();
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, [enabled, update, anchorRef, floatingRef]);
return {
style: {
position: 'fixed',
top: result.top,
left: result.left,
zIndex: 200,
},
actualPlacement: result.actualPlacement,
};
}