feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
type HorizontalAlign = "start" | "end" | "center";
|
||||
type VerticalAlign = "start" | "end" | "center";
|
||||
type OverlaySide = "bottom" | "right";
|
||||
|
||||
interface UseAnchoredOverlayOptions<TTrigger extends HTMLElement> {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
offset?: number;
|
||||
viewportPadding?: number;
|
||||
side?: OverlaySide;
|
||||
align?: HorizontalAlign;
|
||||
crossAlign?: VerticalAlign;
|
||||
matchTriggerWidth?: boolean;
|
||||
triggerRef?: RefObject<TTrigger | null>;
|
||||
}
|
||||
|
||||
interface OverlayPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
|
||||
open,
|
||||
onClose,
|
||||
offset = 8,
|
||||
viewportPadding = 16,
|
||||
side = "bottom",
|
||||
align = "start",
|
||||
crossAlign = "start",
|
||||
matchTriggerWidth = false,
|
||||
triggerRef: externalTriggerRef,
|
||||
}: UseAnchoredOverlayOptions<TTrigger>) {
|
||||
const internalTriggerRef = useRef<TTrigger | null>(null);
|
||||
const triggerRef = externalTriggerRef ?? internalTriggerRef;
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [position, setPosition] = useState<OverlayPosition>({ top: 0, left: 0 });
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
|
||||
const panelHeight = panelRef.current?.offsetHeight ?? 0;
|
||||
|
||||
let nextTop = rect.bottom + offset;
|
||||
let nextLeft = rect.left;
|
||||
|
||||
if (side === "right") {
|
||||
nextLeft = rect.right + offset;
|
||||
if (crossAlign === "center") {
|
||||
nextTop = rect.top + rect.height / 2 - panelHeight / 2;
|
||||
} else if (crossAlign === "end") {
|
||||
nextTop = rect.bottom - panelHeight;
|
||||
} else {
|
||||
nextTop = rect.top;
|
||||
}
|
||||
} else {
|
||||
if (align === "end") {
|
||||
nextLeft = rect.right - panelWidth;
|
||||
} else if (align === "center") {
|
||||
nextLeft = rect.left + rect.width / 2 - panelWidth / 2;
|
||||
}
|
||||
|
||||
nextTop = rect.bottom + offset;
|
||||
const nextBottom = nextTop + panelHeight;
|
||||
const flippedTop = rect.top - panelHeight - offset;
|
||||
if (panelHeight > 0 && nextBottom > window.innerHeight - viewportPadding && flippedTop >= viewportPadding) {
|
||||
nextTop = flippedTop;
|
||||
}
|
||||
}
|
||||
|
||||
const boundedLeft = Math.min(
|
||||
Math.max(nextLeft, viewportPadding),
|
||||
Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding),
|
||||
);
|
||||
const boundedTop = Math.min(
|
||||
Math.max(nextTop, viewportPadding),
|
||||
Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding),
|
||||
);
|
||||
|
||||
setPosition({
|
||||
top: boundedTop,
|
||||
left: boundedLeft,
|
||||
...(matchTriggerWidth ? { minWidth: rect.width } : {}),
|
||||
});
|
||||
}, [align, crossAlign, matchTriggerWidth, offset, side, triggerRef, viewportPadding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [onClose, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
updatePosition();
|
||||
const rafId = window.requestAnimationFrame(updatePosition);
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
updatePosition();
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}, [onClose, updatePosition]);
|
||||
|
||||
return {
|
||||
triggerRef,
|
||||
panelRef,
|
||||
position,
|
||||
updatePosition,
|
||||
handleOpenChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
|
||||
|
||||
type PopoverAnchor =
|
||||
| { kind: "point"; x: number; y: number }
|
||||
| { kind: "element"; element: HTMLElement };
|
||||
|
||||
type PopoverSide = "bottom" | "right";
|
||||
type PopoverAlign = "start" | "end" | "center";
|
||||
|
||||
interface UseViewportPopoverOptions {
|
||||
anchor: PopoverAnchor;
|
||||
width: number;
|
||||
estimatedHeight: number;
|
||||
onClose: () => void;
|
||||
side?: PopoverSide;
|
||||
align?: PopoverAlign;
|
||||
offset?: number;
|
||||
viewportPadding?: number;
|
||||
ignoreElements?: Array<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export function useViewportPopover({
|
||||
anchor,
|
||||
width,
|
||||
estimatedHeight,
|
||||
onClose,
|
||||
side = "bottom",
|
||||
align = "start",
|
||||
offset = 8,
|
||||
viewportPadding = 16,
|
||||
ignoreElements = [],
|
||||
}: UseViewportPopoverOptions) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
if (ref.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if (ignoreElements.some((element) => element?.contains(target))) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [ignoreElements, onClose]);
|
||||
|
||||
const style = useMemo<CSSProperties>(() => {
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
|
||||
if (anchor.kind === "element") {
|
||||
const rect = anchor.element.getBoundingClientRect();
|
||||
if (side === "right") {
|
||||
left = rect.right + offset;
|
||||
if (align === "end") {
|
||||
top = rect.bottom - estimatedHeight;
|
||||
} else if (align === "center") {
|
||||
top = rect.top + rect.height / 2 - estimatedHeight / 2;
|
||||
} else {
|
||||
top = rect.top;
|
||||
}
|
||||
} else {
|
||||
left = rect.left;
|
||||
if (align === "end") {
|
||||
left = rect.right - width;
|
||||
} else if (align === "center") {
|
||||
left = rect.left + rect.width / 2 - width / 2;
|
||||
}
|
||||
top = rect.bottom + offset;
|
||||
}
|
||||
} else {
|
||||
left = anchor.x;
|
||||
top = anchor.y + offset;
|
||||
|
||||
if (align === "end") {
|
||||
left = anchor.x - width;
|
||||
} else if (align === "center") {
|
||||
left = anchor.x - width / 2;
|
||||
}
|
||||
}
|
||||
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - width - viewportPadding);
|
||||
const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedHeight - viewportPadding);
|
||||
|
||||
return {
|
||||
position: "fixed",
|
||||
left: Math.min(Math.max(left, viewportPadding), maxLeft),
|
||||
top: Math.min(Math.max(top, viewportPadding), maxTop),
|
||||
width,
|
||||
zIndex: 60,
|
||||
};
|
||||
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width]);
|
||||
|
||||
return { ref, style };
|
||||
}
|
||||
Reference in New Issue
Block a user