- Root cause of 'navbar mobile tak bisa pindah halaman': the chatbot FAB (fixed right-4 bottom-5 z-50) overlapped the rightmost mobile bottom-nav items (z-40) and intercepted taps. Raise the FAB above the nav on mobile (bottom above nav, md:bottom-5 on desktop) so it never blocks nav taps. - Mobile bottom nav now mirrors the FULL desktop sidebar (all 7 items: dashboard, messages, voice, media, recordings, moderation, analysis); horizontally scrollable + snap-to-active on narrow screens. - Select dropdown: clamp portal position within viewport + min-width so it never overflows off-screen on mobile triggers near the right edge; larger tap targets on touch. - Input/Textarea: text-base (16px) on touch to prevent iOS auto-zoom on focus, text-sm on >=sm; comfortable mobile min-height for chat input. - Add .hermes/plans/mobile-nav-form-responsive.md spec.
75 lines
2.7 KiB
TypeScript
75 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { useEffect, useRef } from "react";
|
|
import { isActivePath, mobileNavItems } from "@/lib/navigation";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* Mobile bottom tab bar. Shown only < md (the side NavRail is hidden there).
|
|
* Mirrors the FULL desktop sidebar nav (all primary items, same as NavRail) so
|
|
* every page is reachable on mobile. On narrow screens the bar scrolls
|
|
* horizontally (snap) — the active item snaps into view on navigation. Safe-area
|
|
* aware for notched devices.
|
|
*/
|
|
export function MobileNav() {
|
|
const path = usePathname() ?? "/";
|
|
const stripRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Keep the active item visible: snap it into view whenever the route changes.
|
|
useEffect(() => {
|
|
const strip = stripRef.current;
|
|
if (!strip) return;
|
|
const item = mobileNavItems.find((n) => isActivePath(path, n.matchPrefix));
|
|
const el = item
|
|
? strip.querySelector<HTMLElement>(`a[href="${item.href}"]`)
|
|
: null;
|
|
if (el) {
|
|
el.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}
|
|
}, [path]);
|
|
|
|
return (
|
|
<nav className="glass fixed inset-x-0 bottom-0 z-40 md:hidden">
|
|
<div
|
|
ref={stripRef}
|
|
className="flex w-full items-stretch overflow-x-auto overscroll-x-contain px-2 pb-[calc(0.4rem+env(safe-area-inset-bottom))] pt-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:justify-around"
|
|
>
|
|
{mobileNavItems.map((item) => {
|
|
const active = isActivePath(path, item.matchPrefix);
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
aria-label={item.label}
|
|
aria-current={active ? "page" : undefined}
|
|
className={cn(
|
|
"game-nav-item relative flex min-h-[44px] min-w-[60px] flex-1 snap-center flex-col items-center justify-center rounded-t-[20px] px-2 pt-2 pb-[calc(0.4rem+env(safe-area-inset-bottom))] sm:flex-none",
|
|
active
|
|
? "is-active text-signal"
|
|
: "text-ink-faint hover:text-ink-soft",
|
|
)}
|
|
>
|
|
{active && (
|
|
<span
|
|
aria-hidden="true"
|
|
className="absolute inset-x-4 top-0 h-[2px] bg-signal shadow-[0_0_8px_var(--color-signal-glow)]"
|
|
/>
|
|
)}
|
|
<span className="game-sweep" aria-hidden="true">
|
|
<i />
|
|
</span>
|
|
<item.icon
|
|
className="relative z-10 size-[20px]"
|
|
strokeWidth={active ? 2.4 : 2}
|
|
/>
|
|
{item.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|