Hide FAB while ComposeBox is visible, fade in on scroll
Use IntersectionObserver on the ComposeBox wrapper in Feed.tsx to track its visibility. When visible, hide the FAB to avoid redundancy. When the user scrolls past the compose area, the FAB smoothly fades in with an upward slide animation. Extends LayoutStore with fabHidden state that is part of the snapshot so useSyncExternalStore triggers re-renders.
This commit is contained in:
+20
-2
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
@@ -15,6 +15,7 @@ import { useInfiniteSortedPosts } from '@/hooks/useTrending';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { useSetFabHidden } from '@/contexts/LayoutContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FeedItem } from '@/lib/feedUtils';
|
||||
|
||||
@@ -38,6 +39,23 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage }: F
|
||||
const { user } = useCurrentUser();
|
||||
const { muteItems } = useMuteList();
|
||||
const queryClient = useQueryClient();
|
||||
const setFabHidden = useSetFabHidden();
|
||||
|
||||
// Hide FAB while the ComposeBox is visible in the viewport
|
||||
const composeRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = composeRef.current;
|
||||
if (!el || hideCompose) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setFabHidden(entry.isIntersecting),
|
||||
{ threshold: 0 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
setFabHidden(false); // restore FAB when Feed unmounts
|
||||
};
|
||||
}, [hideCompose, setFabHidden]);
|
||||
|
||||
// Tab settings from localStorage
|
||||
const showGlobalFeed = (() => {
|
||||
@@ -160,7 +178,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage }: F
|
||||
|
||||
return (
|
||||
<main className="flex-1 min-w-0">
|
||||
{!hideCompose && <ComposeBox compact />}
|
||||
{!hideCompose && <div ref={composeRef}><ComposeBox compact /></div>}
|
||||
|
||||
{header}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ function PageSkeleton() {
|
||||
|
||||
/** Inner component that reads layout options from the context store. */
|
||||
function MainLayoutInner() {
|
||||
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, noBottomSpacer = false, wrapperClassName } = useLayoutSnapshot();
|
||||
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, noBottomSpacer = false, wrapperClassName, fabHidden } = useLayoutSnapshot();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const { config } = useAppContext();
|
||||
|
||||
@@ -92,8 +92,11 @@ function MainLayoutInner() {
|
||||
<div className={cn("relative flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l border-r border-border bg-background/85")}>
|
||||
<Outlet />
|
||||
{showFAB && (
|
||||
<div className="sticky bottom-fab sidebar:bottom-6 z-30 pointer-events-none flex justify-end pr-6">
|
||||
<div className="pointer-events-auto">
|
||||
<div className={cn(
|
||||
'sticky bottom-fab sidebar:bottom-6 z-30 pointer-events-none flex justify-end pr-6 transition-all duration-300',
|
||||
fabHidden ? 'opacity-0 translate-y-4' : 'opacity-100 translate-y-0',
|
||||
)}>
|
||||
<div className={cn('pointer-events-auto', fabHidden && 'pointer-events-none')}>
|
||||
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,11 +26,22 @@ const EMPTY: LayoutOptions = {};
|
||||
* A mutable store that holds the current layout options.
|
||||
* Pages call `setOptions` to update, and MainLayout subscribes via useSyncExternalStore.
|
||||
*/
|
||||
/** Snapshot returned by the layout store, combining page options with dynamic state. */
|
||||
export type LayoutSnapshot = LayoutOptions & { fabHidden: boolean };
|
||||
|
||||
const EMPTY_SNAPSHOT: LayoutSnapshot = { fabHidden: false };
|
||||
|
||||
export class LayoutStore {
|
||||
private options: LayoutOptions = EMPTY;
|
||||
private _fabHidden = false;
|
||||
private _snapshot: LayoutSnapshot = EMPTY_SNAPSHOT;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): LayoutOptions => this.options;
|
||||
private buildSnapshot(): void {
|
||||
this._snapshot = { ...this.options, fabHidden: this._fabHidden };
|
||||
}
|
||||
|
||||
getSnapshot = (): LayoutSnapshot => this._snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
@@ -40,12 +51,22 @@ export class LayoutStore {
|
||||
setOptions = (next: LayoutOptions): void => {
|
||||
if (this.options === next) return;
|
||||
this.options = next;
|
||||
this.buildSnapshot();
|
||||
this.listeners.forEach((l) => l());
|
||||
};
|
||||
|
||||
setFabHidden = (hidden: boolean): void => {
|
||||
if (this._fabHidden === hidden) return;
|
||||
this._fabHidden = hidden;
|
||||
this.buildSnapshot();
|
||||
this.listeners.forEach((l) => l());
|
||||
};
|
||||
|
||||
reset = (): void => {
|
||||
if (this.options === EMPTY) return;
|
||||
if (this.options === EMPTY && !this._fabHidden) return;
|
||||
this.options = EMPTY;
|
||||
this._fabHidden = false;
|
||||
this._snapshot = EMPTY_SNAPSHOT;
|
||||
this.listeners.forEach((l) => l());
|
||||
};
|
||||
}
|
||||
@@ -101,7 +122,13 @@ export function useLayoutOptions(options: LayoutOptions): void {
|
||||
}
|
||||
|
||||
/** Hook for MainLayout to read the current layout options reactively. */
|
||||
export function useLayoutSnapshot(): LayoutOptions {
|
||||
export function useLayoutSnapshot(): LayoutSnapshot {
|
||||
const store = useLayoutStore();
|
||||
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
||||
}
|
||||
|
||||
/** Hook for components to signal that the FAB should be hidden (e.g., ComposeBox is visible). */
|
||||
export function useSetFabHidden(): (hidden: boolean) => void {
|
||||
const store = useLayoutStore();
|
||||
return store.setFabHidden;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user