Files
multica/packages/ui/hooks/use-scroll-fade.ts
Naiyuan Qing cde3867d3b feat(sidebar): top/bottom scroll fade mask (MUL-2150) (#2536)
* 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>
2026-05-13 19:02:08 +08:00

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,
};
}