Files
eranos/src/components/SubHeaderBar.tsx
T
Chad Curtis 207794e714 Restyle navigation with V-angled bars and Agora bolt feed button
Replace the smooth arc shapes shared by the mobile top bar, sub-header
tabs, and bottom nav with angled V polylines centralized in
ArcBackground. The top bar and sub-header now use flat rectangles, and
the bottom nav has a sharp V apex that cradles a centered Agora-bolt
Feed button. The bottom nav row layout changes from
[Home, Search, Notifications, Profile] to
[Search, Communities, _apex_, Notifications, World] with smaller outer
items, and the apex links to the configured home/feed page with
scroll-to-top + invalidation on re-tap.

Also drop the redundant 'Feed' page header on the home feed and the
border under the compact ComposeBox so it blends with the tabs strip
below it.
2026-05-15 19:37:23 -07:00

195 lines
7.9 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ArcBackground } from '@/components/ArcBackground';
import { useNavHidden } from '@/contexts/LayoutContext';
import { SubHeaderBarContext } from '@/components/SubHeaderBarContext';
interface HoverSlice {
left: number;
width: number;
}
interface SubHeaderBarProps {
children: React.ReactNode;
/** Extra classes on the outer wrapper (e.g. shrink-0). */
className?: string;
/** Extra classes on the inner flex container holding the tabs. */
innerClassName?: string;
/** Replace the decorative arc with a plain rectangle. */
noArc?: boolean;
/** Keep the bar visible when the mobile top bar hides (slides to top-0 instead of off-screen). */
pinned?: boolean;
}
/**
* Shared sticky sub-header bar with a unified arc+background drawn as a single
* SVG shape. Eliminates the sub-pixel seam between a bg-background/80 container
* and a separate SVG arc overlay that can appear during scroll/animation.
*
* Used by all tab bars (Feed, Search, Notifications, etc.) and the MobileTopBar
* fallback arc.
*/
export function SubHeaderBar({ children, className, innerClassName, noArc: _noArc, pinned }: SubHeaderBarProps) {
const [hover, setHover] = useState<HoverSlice | null>(null);
const [active, setActive] = useState<HoverSlice | null>(null);
const navHidden = useNavHidden();
// Track whether the sticky bar has actually reached the top of the viewport
// by watching getBoundingClientRect().top on scroll. We show the safe-area
// padding only once the bar's top edge is at or above the safe-area boundary,
// preventing the spacer from appearing while the bar is still mid-page.
const barRef = useRef<HTMLDivElement>(null);
const [atTop, setAtTop] = useState(false);
// Horizontal overflow scroll arrows (desktop only)
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkOverflow = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const tolerance = 2; // sub-pixel rounding tolerance
setCanScrollLeft(el.scrollLeft > tolerance);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - tolerance);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
checkOverflow();
el.addEventListener('scroll', checkOverflow, { passive: true });
const ro = new ResizeObserver(checkOverflow);
ro.observe(el);
return () => {
el.removeEventListener('scroll', checkOverflow);
ro.disconnect();
};
}, [checkOverflow]);
// Also re-check overflow when children change (new tabs added/removed)
useEffect(() => {
checkOverflow();
}, [children, checkOverflow]);
const scrollBy = (direction: 'left' | 'right') => {
const el = scrollRef.current;
if (!el) return;
const amount = el.clientWidth * 0.6;
el.scrollBy({ left: direction === 'left' ? -amount : amount, behavior: 'smooth' });
};
useEffect(() => {
if (!pinned) return;
// Measure safe-area-inset-top once by reading it via a throw-away element.
const probe = document.createElement('div');
probe.style.cssText = 'position:fixed;top:var(--safe-area-inset-top,env(safe-area-inset-top,0px));left:0;width:0;height:0;visibility:hidden;pointer-events:none';
document.body.appendChild(probe);
const safeAreaTop = probe.getBoundingClientRect().top;
document.body.removeChild(probe);
const check = () => {
const bar = barRef.current;
if (!bar) return;
setAtTop(bar.getBoundingClientRect().top <= safeAreaTop);
};
window.addEventListener('scroll', check, { passive: true });
check();
return () => window.removeEventListener('scroll', check);
}, [pinned]);
const showSafeAreaPadding = pinned && navHidden && atTop;
return (
<SubHeaderBarContext.Provider value={{ onHover: setHover, onActive: setActive, scrollContainerRef: scrollRef }}>
<div
ref={barRef}
className={cn(
'relative sticky top-mobile-bar sidebar:top-0 z-10',
pinned
? 'max-sidebar:transition-[top,padding-top] max-sidebar:duration-300 max-sidebar:ease-in-out'
: 'max-sidebar:transition-transform max-sidebar:duration-300 max-sidebar:ease-in-out',
navHidden && (pinned ? 'max-sidebar:!top-0' : 'nav-hidden-slide'),
showSafeAreaPadding && 'max-sidebar:safe-area-top',
className,
)}
>
{/* Safe-area fill — visible only when pinned and bar is at the top, covers the
padding zone above the tabs with the same translucent bg as the MobileTopBar. */}
{showSafeAreaPadding && (
<div
className="absolute top-0 left-0 right-0 bg-background/85 sidebar:hidden"
style={{ height: 'var(--safe-area-inset-top, env(safe-area-inset-top, 0px))' }}
/>
)}
{/* Inner wrapper holds the ArcBackground and tab content. */}
<div className="relative">
<ArcBackground variant="rect" />
{/* Per-tab hover highlight: a flat-bottomed slab clipped to the hovered tab's x-slice */}
{hover && (
<svg
aria-hidden
className="absolute top-0 left-0 w-full h-full pointer-events-none"
style={{
clipPath: `inset(0 calc(100% - ${hover.left + hover.width}px) 0 ${hover.left}px)`,
}}
viewBox="0 0 100 64"
preserveAspectRatio="none"
>
<path d="M0,0 L100,0 L100,64 L0,64 Z" className="fill-secondary/40" />
</svg>
)}
{/* Active tab indicator: a flat underline along the bottom edge, clipped to the active tab's x-slice */}
{active && (
<svg
aria-hidden
className="absolute top-0 left-0 w-full h-full pointer-events-none"
style={{
clipPath: `inset(0 calc(100% - ${active.left + active.width}px) 0 ${active.left}px)`,
}}
viewBox="0 0 100 64"
preserveAspectRatio="none"
>
<path d="M0,62 L100,62" fill="none" className="stroke-primary" strokeWidth="3" vectorEffect="non-scaling-stroke" />
</svg>
)}
{/* Tab content sits above the SVG background */}
<div className="relative">
{/* Left scroll arrow — desktop only, shown when overflowing */}
{canScrollLeft && (
<button
type="button"
aria-label="Scroll tabs left"
onClick={() => scrollBy('left')}
className="hidden sidebar:flex absolute left-0 top-0 bottom-0 z-10 items-center pl-0.5 pr-1 bg-gradient-to-r from-background via-background to-transparent cursor-pointer"
>
<ChevronLeft className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
</button>
)}
<div
ref={scrollRef}
className={cn('relative flex overflow-x-auto scrollbar-none py-1', innerClassName)}
>
{children}
</div>
{/* Right scroll arrow — desktop only, shown when overflowing */}
{canScrollRight && (
<button
type="button"
aria-label="Scroll tabs right"
onClick={() => scrollBy('right')}
className="hidden sidebar:flex absolute right-0 top-0 bottom-0 z-10 items-center pr-0.5 pl-1 bg-gradient-to-l from-background via-background to-transparent cursor-pointer"
>
<ChevronRight className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
</button>
)}
</div>
</div>
</div>
</SubHeaderBarContext.Provider>
);
}