fix(frontend): mobile navbar reachable to all pages + responsive form controls
- 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.
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
# GMW Frontend — Mobile Nav + Form Controls Responsive Pass
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Two mobile UX defects reported by user:
|
||||||
|
|
||||||
|
1. **Mobile navbar can't navigate between pages** (`tidak bisa pindah halaman`).
|
||||||
|
- **Root cause A (click-block):** the floating chatbot FAB
|
||||||
|
(`chatbot.tsx` line ~125: `fixed right-4 bottom-5 z-50 size-11`) overlaps the
|
||||||
|
mobile bottom nav (`MobileNav` is `fixed bottom-0 z-40`). Because the FAB is
|
||||||
|
`z-50` and sits at `bottom-5` on the right, it intercepts taps on the
|
||||||
|
rightmost bottom-nav items (Voice/Media). Verified in Playwright: click on
|
||||||
|
`Media` nav item is blocked by `button[aria-label="Open neural HUD assistant"]`.
|
||||||
|
- **Root cause B (missing destinations):** `mobileNavItems` in
|
||||||
|
`src/lib/navigation.ts` filters `navItems` to only
|
||||||
|
`['/dashboard','/messages','/voice','/media']`, so `/recordings`,
|
||||||
|
`/moderation`, `/analysis` are unreachable from the mobile bottom bar.
|
||||||
|
- **Chosen design (user directive):** extend the mobile bottom bar to **all 7
|
||||||
|
nav items**, horizontally **scrollable/flex-wrap** so they fit on narrow
|
||||||
|
screens, following the desktop sidebar's items (same set as `navItems`).
|
||||||
|
|
||||||
|
2. **Form controls not responsive on mobile** (`perbagus textfield, dropdown,
|
||||||
|
lainnya agar responsif di mobile`).
|
||||||
|
- `Select` dropdown (`primitives/select.tsx`) is portalled w/ `fixed`
|
||||||
|
positioning computed from trigger rect only (top/left/width). On a narrow
|
||||||
|
viewport a dropdown anchored near the right edge overflows past the screen's
|
||||||
|
right edge and can be clipped/clickable-outside the visible area. Clamp
|
||||||
|
horizontally within viewport.
|
||||||
|
- `Input`/`Textarea` (`primitives/input.tsx`) use `text-sm` (14px) which on
|
||||||
|
iOS triggers auto-zoom on focus (a known mobile annoyance) and can be small
|
||||||
|
touch targets. Give inputs a comfortable mobile baseline.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
- `src/components/shell/mobile-nav.tsx` — use `navItems` (all 7), make the bar
|
||||||
|
scrollable on narrow widths (overflow-x-auto + snap), keep active indicator.
|
||||||
|
- `src/components/chatbot/chatbot.tsx` — raise FAB above the mobile bottom nav on
|
||||||
|
`md:hidden` (e.g. `bottom-[calc(4.75rem+env(safe-area-inset-bottom))]`),
|
||||||
|
keep `bottom-5` on `md+` so it never overlaps the nav.
|
||||||
|
- `src/lib/navigation.ts` — drop `mobileNavItems` special-case (or repoint to
|
||||||
|
`navItems`); keep `navItems` as the single source.
|
||||||
|
- `src/components/primitives/select.tsx` — clamp dropdown `left` so it stays
|
||||||
|
within viewport; cap width on narrow screens; keep max-height.
|
||||||
|
- `src/components/primitives/input.tsx` — mobile-friendly font size / touch
|
||||||
|
height (16px inputs to avoid iOS zoom; keep desktop as-is via responsive
|
||||||
|
classes).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `pnpm format`, `pnpm lint` (biome clean), `pnpm build` compiles.
|
||||||
|
- Smoke on NON-PROD port (4024) — confirm free first via `ss -ltnp`.
|
||||||
|
- Playwright mobile viewport (390px): ALL 7 nav items present in bottom bar;
|
||||||
|
tapping each navigates (esp. Media rightmost item NOT blocked by FAB); FAB is
|
||||||
|
raised above the nav and no longer intercepts taps.
|
||||||
|
- Select dropdown on a 390px viewport stays fully on-screen (no right overflow).
|
||||||
|
- Commit (no trailer), push, `gh run watch`, verify all 7 live routes 200.
|
||||||
@@ -738,11 +738,7 @@ function MessageRow({
|
|||||||
: "border-hairline bg-surface-2 hover:border-hairline-focus hover:bg-surface"
|
: "border-hairline bg-surface-2 hover:border-hairline-focus hover:bg-surface"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar src={m.avatar_url} name={m.server_nick ?? m.username} size={32} />
|
||||||
src={m.avatar_url}
|
|
||||||
name={m.server_nick ?? m.username}
|
|
||||||
size={32}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate text-xs font-semibold text-ink">
|
<span className="truncate text-xs font-semibold text-ink">
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function Chatbot() {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="Open neural HUD assistant"
|
aria-label="Open neural HUD assistant"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className="fixed right-4 bottom-5 z-50 flex size-11 items-center justify-center rounded-full border border-signal/40 bg-surface text-signal shadow-[0_0_20px_var(--color-signal-glow)] transition-all duration-200 hover:scale-105 hover:border-signal hover:bg-signal hover:text-white"
|
className="fixed right-4 z-50 flex size-11 items-center justify-center rounded-full border border-signal/40 bg-surface text-signal shadow-[0_0_20px_var(--color-signal-glow)] transition-all duration-200 hover:scale-105 hover:border-signal hover:bg-signal hover:text-white bottom-[calc(4.75rem+env(safe-area-inset-bottom))] md:bottom-5"
|
||||||
>
|
>
|
||||||
<Sparkles className="size-5 animate-breathe" />
|
<Sparkles className="size-5 animate-breathe" />
|
||||||
</button>
|
</button>
|
||||||
@@ -234,7 +234,7 @@ export function Chatbot() {
|
|||||||
placeholder="Transmit instruction to core..."
|
placeholder="Transmit instruction to core..."
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
className="flex-1 rounded-[6px] border border-hairline bg-surface-2 px-3 py-1.5 font-mono text-xs text-ink placeholder:text-ink-faint focus:border-signal focus:outline-none"
|
className="min-h-[38px] flex-1 rounded-[6px] border border-hairline bg-surface-2 px-3 py-1.5 font-mono text-xs text-ink placeholder:text-ink-faint focus:border-signal focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ export function Input({
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 w-full rounded-[8px] bg-surface-2 border border-hairline px-3.5 text-sm text-ink",
|
"h-10 w-full rounded-[8px] bg-surface-2 border border-hairline px-3.5 text-ink",
|
||||||
|
// text-base (16px) on touch so iOS Safari doesn't auto-zoom on focus;
|
||||||
|
// back to text-sm on ≥sm where a physical pointer/keyboard is expected.
|
||||||
|
"text-base sm:text-sm",
|
||||||
"placeholder:text-ink-faint transition-colors",
|
"placeholder:text-ink-faint transition-colors",
|
||||||
"focus:outline-none focus:border-signal/50 focus:bg-surface",
|
"focus:outline-none focus:border-signal/50 focus:bg-surface",
|
||||||
className,
|
className,
|
||||||
@@ -24,7 +27,9 @@ export function Textarea({
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-[8px] bg-surface-2 border border-hairline px-3.5 py-2.5 text-sm text-ink",
|
"w-full rounded-[8px] bg-surface-2 border border-hairline px-3.5 py-2.5 text-ink",
|
||||||
|
// text-base (16px) on touch to avoid iOS auto-zoom on focus.
|
||||||
|
"text-base sm:text-sm",
|
||||||
"placeholder:text-ink-faint transition-colors resize-none",
|
"placeholder:text-ink-faint transition-colors resize-none",
|
||||||
"focus:outline-none focus:border-signal/50 focus:bg-surface",
|
"focus:outline-none focus:border-signal/50 focus:bg-surface",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -46,11 +46,17 @@ export function Select({
|
|||||||
width: 0,
|
width: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Measure trigger to position the portalled dropdown
|
// Measure trigger to position the portalled dropdown.
|
||||||
|
// Clamp horizontally inside the viewport so it never overflows on narrow
|
||||||
|
// (mobile) screens — a trigger near the right edge would otherwise render the
|
||||||
|
// dropdown partly off-screen and un-clickable.
|
||||||
const measure = useCallback(() => {
|
const measure = useCallback(() => {
|
||||||
if (!triggerRef.current) return;
|
if (!triggerRef.current) return;
|
||||||
const rect = triggerRef.current.getBoundingClientRect();
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
setPos({ top: rect.bottom + 6, left: rect.left, width: rect.width });
|
const w = Math.max(rect.width, 160); // never narrower than a comfortable minimum
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const left = Math.min(Math.max(rect.left, 8), vw - w - 8);
|
||||||
|
setPos({ top: rect.bottom + 6, left, width: w });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -149,7 +155,7 @@ export function Select({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between gap-2 rounded-[8px] px-3 py-2 text-left text-sm transition-colors",
|
"flex w-full min-h-[38px] items-center justify-between gap-2 rounded-[8px] px-3 py-2 text-left text-sm transition-colors sm:min-h-0 sm:py-2",
|
||||||
o.value === value
|
o.value === value
|
||||||
? "bg-signal/15 text-signal"
|
? "bg-signal/15 text-signal"
|
||||||
: "text-ink hover:bg-surface",
|
: "text-ink hover:bg-surface",
|
||||||
|
|||||||
@@ -2,51 +2,73 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { isActivePath, mobileNavItems } from "@/lib/navigation";
|
import { isActivePath, mobileNavItems } from "@/lib/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile bottom tab bar. Shown only < md (the side NavRail is hidden there).
|
* Mobile bottom tab bar. Shown only < md (the side NavRail is hidden there).
|
||||||
* Mirrors the desktop nav items but as a thumb-friendly dock with labels and a
|
* Mirrors the FULL desktop sidebar nav (all primary items, same as NavRail) so
|
||||||
* top active indicator. Safe-area aware for notched devices.
|
* 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() {
|
export function MobileNav() {
|
||||||
const path = usePathname() ?? "/";
|
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 (
|
return (
|
||||||
<nav className="glass fixed inset-x-0 bottom-0 z-40 flex items-stretch justify-around rounded-t-[20px] px-2 pb-[calc(0.4rem+env(safe-area-inset-bottom))] pt-2 md:hidden">
|
<nav className="glass fixed inset-x-0 bottom-0 z-40 md:hidden">
|
||||||
{mobileNavItems.map((item) => {
|
<div
|
||||||
const active = isActivePath(path, item.matchPrefix);
|
ref={stripRef}
|
||||||
return (
|
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"
|
||||||
<Link
|
>
|
||||||
key={item.href}
|
{mobileNavItems.map((item) => {
|
||||||
href={item.href}
|
const active = isActivePath(path, item.matchPrefix);
|
||||||
aria-label={item.label}
|
return (
|
||||||
aria-current={active ? "page" : undefined}
|
<Link
|
||||||
className={cn(
|
key={item.href}
|
||||||
"game-nav-item relative flex min-h-[44px] flex-1 flex-col items-center justify-center rounded-t-[20px] px-2 pt-2 pb-[calc(0.4rem+env(safe-area-inset-bottom))] md:hidden",
|
href={item.href}
|
||||||
active
|
aria-label={item.label}
|
||||||
? "is-active text-signal"
|
aria-current={active ? "page" : undefined}
|
||||||
: "text-ink-faint hover:text-ink-soft",
|
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
|
||||||
{active && (
|
? "is-active text-signal"
|
||||||
<span
|
: "text-ink-faint hover:text-ink-soft",
|
||||||
aria-hidden="true"
|
)}
|
||||||
className="absolute inset-x-4 top-0 h-[2px] bg-signal shadow-[0_0_8px_var(--color-signal-glow)]"
|
>
|
||||||
|
{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}
|
||||||
<span className="game-sweep" aria-hidden="true">
|
</Link>
|
||||||
<i />
|
);
|
||||||
</span>
|
})}
|
||||||
<item.icon
|
</div>
|
||||||
className="relative z-10 size-[20px]"
|
|
||||||
strokeWidth={active ? 2.4 : 2}
|
|
||||||
/>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ function NavItem({
|
|||||||
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
}) {
|
}) {
|
||||||
const itemRef = useRef<HTMLAnchorElement>(null);
|
const itemRef = useRef<HTMLAnchorElement>(null);
|
||||||
const [tipPos, setTipPos] = useState<{ top: number; left: number } | null>(null);
|
const [tipPos, setTipPos] = useState<{ top: number; left: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [hovering, setHovering] = useState(false);
|
const [hovering, setHovering] = useState(false);
|
||||||
|
|
||||||
// Measure the item and position the tooltip via a Portal on document.body
|
// Measure the item and position the tooltip via a Portal on document.body
|
||||||
|
|||||||
@@ -66,11 +66,11 @@ export const navItems: NavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile bottom bar items (subset of primary nav).
|
* Mobile bottom bar items. Mirrors the FULL desktop sidebar (all primary nav
|
||||||
|
* items) so every page is reachable on mobile; the bar scrolls horizontally on
|
||||||
|
* narrow screens (see MobileNav).
|
||||||
*/
|
*/
|
||||||
export const mobileNavItems: NavItem[] = navItems.filter((item) =>
|
export const mobileNavItems: NavItem[] = navItems;
|
||||||
["/dashboard", "/messages", "/voice", "/media"].includes(item.href),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type NavItemId = (typeof navItems)[number]["href"];
|
export type NavItemId = (typeof navItems)[number]["href"];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user