mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
* feat(sidebar): top/bottom scroll fade mask (MUL-2150) Apply useScrollFade to SidebarContent so the menu list softly fades into the header / footer when overflowing, matching the existing pattern used in chat list and onboarding steps. Co-authored-by: multica-agent <github@multica.ai> * fix(ui): useScrollFade re-evaluates on content mutations ResizeObserver only fires on the observed element's own box. When a flex / auto-height container's children grow asynchronously (sidebar pinned items loading from TanStack Query, collapsibles expanding), scrollHeight changes but clientHeight does not — mask stayed 'none' until the user scrolled. Add a MutationObserver on childList to recompute fade when content is inserted or removed. Co-authored-by: multica-agent <github@multica.ai> * test(paths): include squads in workspace route consistency check main added the squads parameterless route to paths.workspace() in #2505 but the C4 consistency assertion wasn't updated, turning frontend CI red on every PR. Add 'squads' to both the parameterless-method set and the segment-mapping table. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
77 lines
2.3 KiB
TypeScript
77 lines
2.3 KiB
TypeScript
import { type RefObject, type CSSProperties, useEffect, useState, useCallback } from "react";
|
|
|
|
/**
|
|
* Returns a dynamic maskImage style based on scroll position.
|
|
* - At top → fade bottom only
|
|
* - At bottom → fade top only
|
|
* - In middle → fade both
|
|
* - No overflow → undefined (no mask)
|
|
*/
|
|
export function useScrollFade(
|
|
ref: RefObject<HTMLElement | null>,
|
|
fadeSize = 32
|
|
): CSSProperties | undefined {
|
|
const [fade, setFade] = useState<"none" | "top" | "bottom" | "both">("none");
|
|
|
|
const update = useCallback(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = el;
|
|
const scrollable = scrollHeight - clientHeight;
|
|
|
|
if (scrollable <= 0) {
|
|
setFade("none");
|
|
return;
|
|
}
|
|
|
|
const atTop = scrollTop <= 1;
|
|
const atBottom = scrollTop >= scrollable - 1;
|
|
|
|
if (atTop && atBottom) setFade("none");
|
|
else if (atTop) setFade("bottom");
|
|
else if (atBottom) setFade("top");
|
|
else setFade("both");
|
|
}, [ref]);
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
|
|
const frame = requestAnimationFrame(update);
|
|
|
|
el.addEventListener("scroll", update, { passive: true });
|
|
const ro = new ResizeObserver(update);
|
|
ro.observe(el);
|
|
// ResizeObserver only fires on the container's own box. When children
|
|
// grow inside a flex/auto-height parent (e.g. async-loaded list items,
|
|
// collapsibles), scrollHeight changes but clientHeight does not — the
|
|
// mask would stay "none" until the user scrolls. MutationObserver on
|
|
// childList catches those content insertions.
|
|
const mo = new MutationObserver(update);
|
|
mo.observe(el, { childList: true, subtree: true });
|
|
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
el.removeEventListener("scroll", update);
|
|
ro.disconnect();
|
|
mo.disconnect();
|
|
};
|
|
}, [ref, update]);
|
|
|
|
if (fade === "none") return undefined;
|
|
|
|
const top = fade === "top" || fade === "both" ? `transparent 0%, black ${fadeSize}px` : "black 0%";
|
|
const bottom =
|
|
fade === "bottom" || fade === "both"
|
|
? `black calc(100% - ${fadeSize}px), transparent 100%`
|
|
: "black 100%";
|
|
|
|
const gradient = `linear-gradient(to bottom, ${top}, ${bottom})`;
|
|
|
|
return {
|
|
maskImage: gradient,
|
|
WebkitMaskImage: gradient,
|
|
};
|
|
}
|