Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,7 @@ const AppContent = () => {
tabs={navItems.filter(item => item.id !== 'admin')}
activeTab={currentView === 'admin' ? 'settings' : currentTab}
onSelect={id => handleTabSelect(id as ViewKey)}
pageKey={currentView}
/>
</div>

Expand Down
57 changes: 56 additions & 1 deletion src/components/BottomNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,18 @@ interface NavTab {
* strings give the page, so it stays, and its tab takes a few pixels more
* than the rest. Each tab is rounded so the focus ring nests in the island
* instead of cutting a square into the next tab's pill.
*
* Reading down a long page, the bar slides out of the way so it no longer
* sits over what's being read; a short scroll back up, the top of the page or
* its end brings it back, and so does every new page. `pageKey` is the page
* being shown, so the bar knows when that happens.
*/
const BottomNav: React.FC<{
tabs: NavTab[];
activeTab: string;
onSelect: (id: string) => void;
}> = ({ tabs, activeTab, onSelect }) => {
pageKey?: string;
}> = ({ tabs, activeTab, onSelect, pageKey }) => {
const navRef = useRef<HTMLElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
const marks = useRef<Record<string, HTMLSpanElement | null>>({});
Expand Down Expand Up @@ -81,6 +87,8 @@ const BottomNav: React.FC<{
};
}, []);

const hidden = useScrollAway(pageKey);

useEffect(() => {
if (!pill || placed) return;
const frame = requestAnimationFrame(() => setPlaced(true));
Expand All @@ -90,6 +98,7 @@ const BottomNav: React.FC<{
return (
<nav
ref={navRef}
data-hidden={hidden ? '' : undefined}
className="bottom-nav fixed bg-[var(--surface)] left-4 right-4 bottom-[calc(0.75rem+env(safe-area-inset-bottom,0px))] z-40 md:hidden rounded-[var(--radius-xl)] border-(length:--hairline) border-[var(--border)] shadow-[var(--shadow-xs)]"
>
<div ref={rowRef} className="relative flex items-stretch p-1.5 gap-0.5">
Expand Down Expand Up @@ -128,4 +137,50 @@ const BottomNav: React.FC<{
);
};

/** How far the page must move one way before the bar follows, so a finger
* resting on the glass or a small correction doesn't make it flicker. */
const TRAVEL = 24;
/** Near the top of a page the bar always shows. */
const TOP = 48;

/**
* Whether the bar should be out of the way: true once the page has been
* scrolled down a little, false again after a little scroll up, at the top,
* at the end, or on a new page. Any scroller counts, the page area or one
* inside it, since scroll events are caught on their way down.
*/
function useScrollAway(pageKey?: string): boolean {
// Hidden on the page it was hidden on; a new page starts with it shown.
const [hiddenOn, setHiddenOn] = useState<string | undefined | null>(null);

useEffect(() => {
const setHidden = (hide: boolean) => setHiddenOn(hide ? pageKey : null);
const last = new WeakMap<EventTarget, number>();
let travel = 0;
const onScroll = (e: Event) => {
const el = e.target instanceof Element ? e.target : (document.scrollingElement ?? document.documentElement);
const top = el.scrollTop;
const prev = last.get(el);
last.set(el, top);
// A sideways scroller (a chart, a row of chips) moves no rows.
if (prev === undefined || top === prev) return;
const max = el.scrollHeight - el.clientHeight;
if (top <= TOP || top >= max - 2) {
travel = 0;
setHidden(false);
return;
}
const delta = top - prev;
// Start counting again whenever the direction turns.
travel = Math.sign(delta) === Math.sign(travel) ? travel + delta : delta;
if (travel > TRAVEL) setHidden(true);
else if (travel < -TRAVEL) setHidden(false);
};
document.addEventListener('scroll', onScroll, { capture: true, passive: true });
return () => document.removeEventListener('scroll', onScroll, { capture: true });
}, [pageKey]);

return hiddenOn !== null && hiddenOn === pageKey;
}

export default BottomNav;
22 changes: 22 additions & 0 deletions src/styles/motion.css
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,28 @@
mix-blend-mode: normal;
}

/* Scrolled out of the way while a page is read (BottomNav's useScrollAway):
it eases down past the screen's edge and back, never bounces. Someone
moving through it with a keyboard keeps it in view. */
.bottom-nav {
transition:
translate var(--duration-slow) var(--ease-out),
opacity var(--duration-slow) var(--ease-out);
}
.bottom-nav[data-hidden]:not(:focus-within) {
translate: 0 calc(100% + 0.75rem + env(safe-area-inset-bottom, 0px));
opacity: 0;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.bottom-nav {
transition: opacity var(--duration-base) var(--ease-standard);
}
.bottom-nav[data-hidden]:not(:focus-within) {
translate: none;
}
}

@keyframes slide-in-from-end {
from {
transform: translateX(calc(100% * var(--move-dir)));
Expand Down
Loading