fix(ui): smooth desktop sidebar sticky transitions (#2528)

* fix(ui): blend vibrant sidebar transitions

* fix(ui): fade sidebar content below header

* fix(ui): smooth vibrant sidebar sticky headers

* fix(ui): scope sticky fade to desktop

* fix(ui): respect sticky header preference
This commit is contained in:
ChangeHow
2026-07-30 17:51:23 +03:00
committed by GitHub
parent 83a66d0a6d
commit 8130381f6c
8 changed files with 221 additions and 162 deletions
@@ -7,78 +7,43 @@ type Args = {
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
};
type StickyHeaderArgs = {
enabled: boolean;
isDesktopShellRuntime: boolean;
};
export const useStickyHeader = (args: StickyHeaderArgs) => {
const { enabled, isDesktopShellRuntime } = args;
const [sentinel, setSentinel] = React.useState<HTMLDivElement | null>(null);
const [isStuck, setIsStuck] = React.useState(false);
const sentinelRef = React.useCallback((node: HTMLDivElement | null) => {
setSentinel(node);
}, []);
React.useEffect(() => {
if (!enabled || !isDesktopShellRuntime || !sentinel) {
setIsStuck(false);
return;
}
const observer = new IntersectionObserver(([entry]) => {
setIsStuck(entry.intersectionRatio < 1);
}, { threshold: 1 });
observer.observe(sentinel);
return () => observer.disconnect();
}, [enabled, isDesktopShellRuntime, sentinel]);
return { isStuck, sentinelRef };
};
export const useStickyProjectHeaders = (args: Args): Set<string> => {
const { enabled = true, isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args;
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
React.useEffect(() => {
if (enabled && isDesktopShellRuntime) {
if (!enabled || !isDesktopShellRuntime) {
setStuckProjectHeaders((prev) => (prev.size === 0 ? prev : new Set()));
return;
}
setStuckProjectHeaders((prev) => (prev.size === 0 ? prev : new Set()));
}, [enabled, isDesktopShellRuntime]);
React.useEffect(() => {
if (!enabled || !isDesktopShellRuntime) {
const firstSentinel = Array.from(projectHeaderSentinelRefs.current.values()).find((el) => el !== null);
const root = firstSentinel?.closest<HTMLElement>('.oc-sidebar-scroller') ?? null;
if (!root) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const projectId = (entry.target as HTMLElement).dataset.projectId;
if (!projectId) {
return;
setStuckProjectHeaders((prev) => {
const next = new Set(prev);
let changed = false;
for (const entry of entries) {
const projectId = (entry.target as HTMLElement).dataset.projectId;
if (!projectId) continue;
const rootTop = entry.rootBounds?.top ?? root.getBoundingClientRect().top;
const isAboveScroller = !entry.isIntersecting && entry.boundingClientRect.top < rootTop;
if (next.has(projectId) === isAboveScroller) continue;
changed = true;
if (isAboveScroller) next.add(projectId);
else next.delete(projectId);
}
setStuckProjectHeaders((prev) => {
if (!entry.isIntersecting) {
if (prev.has(projectId)) return prev;
const next = new Set(prev);
next.add(projectId);
return next;
}
if (!prev.has(projectId)) return prev;
const next = new Set(prev);
next.delete(projectId);
return next;
});
return changed ? next : prev;
});
},
{ threshold: 0 },
{ root, threshold: 0 },
);
projectHeaderSentinelRefs.current.forEach((el) => {