Drawer removed (phones only; desktop rail untouched) - MobileSidebar, sidebarOpen/useSidebarOpen, MenuIcon and the drawer's back-gesture layer are gone; the shared nav content now serves only the desktop rail (dead mobile branches deleted). No drawer-specific CSS existed; the three drawer strings (header.openMenu, sidebar.closeMenu, common.menu) and library.importAria are removed. - The header drops the hamburger: logo + name on the left, the single theme chip on the right. Five-tab bottom bar - Library / Progress / New deck (prominent centre +) / Exam / Settings, in that order; Progress and Exam reuse the existing /stats and /exam screens, Settings keeps opening the dialog. Sliding pill, icon bounce, press feedback, re-tap-to-top and safe-area padding kept; every item is >=44px (72px wide at 360px). - New short tab.* keys (EN + CS) so all five labels stay on one line at 360px — 'Zkouškový režim' became 'Zkouška'. No ellipsis anywhere. - The bar is mounted once in AppHeader so library, progress, exam and share get it; the wizards keep their own bottom action bar and the tab bar steps aside there, exactly as before. Re-homed drawer destinations - Smart Review card loses the Exam link (it is a tab now); Review due and Mistakes stay. - New deck screen (and /homework) get a Study deck / Make my homework segmented switch; the library's homework empty-state button stays. - The phone import icon becomes a ... button opening a sheet with Import JSON and New folder (back-gesture and Escape close it); folder chips still appear only once at least one folder exists, and folder creation stays in the New deck options step. Verified: mobile suite 134 checks (no hamburger/drawer, tab order and 44px targets, label matrix at 360/390 x EN/CS x dark/light x reduced motion, per-tab routing, deep links, Back from every tab, the ... sheet with folder creation) plus regression 74/74, wizard 83/83, declutter 116/116, animation 8/8, native 35/35, i18n 25/25, phase1 38/38, phase2 43/43, phase3 20/20, freeze-loading 6/6. Key parity 740/740. Lint + build clean. SW freezmo-v28.
133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
"use client";
|
|
|
|
import { motion, useReducedMotion } from "framer-motion";
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
|
|
import { CompassIcon, FlameIcon, GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
|
|
import { haptic } from "@/lib/haptics";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import { springSnappy } from "@/lib/motion";
|
|
import { openSettings } from "@/lib/ui-events";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* iOS-style bottom tab bar, phones only: Library · Progress · New deck (the
|
|
* prominent centre button) · Exam mode · Settings. The active item sits on a
|
|
* sliding pill (shared `layoutId`) and its icon gives a small bounce when you
|
|
* land on it; re-tapping the active tab scrolls back to the top.
|
|
*
|
|
* Labels use the short `tab.*` keys so they fit on one line at 360px in both
|
|
* languages — no ellipsis, no wrapping.
|
|
*/
|
|
export function TabBar() {
|
|
const pathname = usePathname();
|
|
const { t } = useI18n();
|
|
const reduceMotion = useReducedMotion();
|
|
|
|
/* The wizards keep their own bottom action bar (Cancel / Next / Freeze) —
|
|
the flow is deliberately focused, so the tab bar steps aside there. */
|
|
const inWizard = pathname === "/create" || pathname === "/homework";
|
|
|
|
const item =
|
|
"relative flex min-h-[52px] min-w-0 flex-1 flex-col items-center justify-center gap-1 py-2 text-[10.5px] font-medium leading-none transition-colors";
|
|
const active = "text-[var(--text)]";
|
|
const idle = "text-[var(--text-subtle)]";
|
|
|
|
/** The pill glides between items; hidden under reduced motion. */
|
|
const pill = (isActive: boolean) =>
|
|
isActive && !reduceMotion ? (
|
|
<motion.span
|
|
aria-hidden
|
|
layoutId="tabbar-active"
|
|
transition={springSnappy}
|
|
className="absolute inset-x-2 inset-y-0.5 rounded-2xl bg-[var(--nav-active-bg)] shadow-[inset_0_0_0_1px_var(--nav-active-border)]"
|
|
/>
|
|
) : null;
|
|
|
|
/** Small icon bounce on the item you just selected. */
|
|
const icon = (children: React.ReactNode, isActive: boolean, key: string) => (
|
|
<motion.span
|
|
key={`${key}-${isActive ? pathname : "idle"}`}
|
|
animate={isActive && !reduceMotion ? { scale: [1, 1.16, 1] } : { scale: 1 }}
|
|
transition={springSnappy}
|
|
className="relative z-10 grid place-items-center"
|
|
>
|
|
{children}
|
|
</motion.span>
|
|
);
|
|
|
|
const label = (text: string, isActive: boolean) => (
|
|
<span className={cn("relative z-10 whitespace-nowrap", isActive ? active : idle)}>{text}</span>
|
|
);
|
|
|
|
const isActive = (key: "library" | "progress" | "create" | "exam") => {
|
|
if (key === "library") return pathname === "/";
|
|
if (key === "progress") return pathname === "/stats";
|
|
// The homework wizard is the other half of the New deck screen.
|
|
if (key === "create") return pathname === "/create" || pathname === "/homework";
|
|
return pathname === "/exam";
|
|
};
|
|
|
|
const link = (
|
|
key: "library" | "progress" | "create" | "exam",
|
|
href: string,
|
|
labelText: string,
|
|
iconNode: React.ReactNode,
|
|
) => {
|
|
const current = isActive(key);
|
|
return (
|
|
<Link
|
|
key={key}
|
|
href={href}
|
|
aria-current={current ? "page" : undefined}
|
|
className={cn(item, current ? active : idle)}
|
|
onClick={(event) => {
|
|
haptic("tap");
|
|
// Re-tapping the active tab scrolls back to the top.
|
|
if (current) {
|
|
event.preventDefault();
|
|
window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
|
|
}
|
|
}}
|
|
>
|
|
{pill(current)}
|
|
{icon(iconNode, current, key)}
|
|
{label(labelText, current)}
|
|
</Link>
|
|
);
|
|
};
|
|
|
|
if (inWizard) return null;
|
|
|
|
return (
|
|
<nav
|
|
aria-label={t("tabbar.main")}
|
|
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden"
|
|
>
|
|
{link("library", "/", t("tab.library"), <HomeIcon className="size-6" />)}
|
|
{link("progress", "/stats", t("tab.progress"), <FlameIcon className="size-6" />)}
|
|
{link(
|
|
"create",
|
|
"/create",
|
|
t("tab.new"),
|
|
<span className="grad-ice grid size-7 place-items-center rounded-full text-[#06111f]">
|
|
<PlusIcon className="size-4" />
|
|
</span>,
|
|
)}
|
|
{link("exam", "/exam", t("tab.exam"), <CompassIcon className="size-6" />)}
|
|
<button
|
|
type="button"
|
|
aria-haspopup="dialog"
|
|
onClick={() => {
|
|
haptic("tap");
|
|
openSettings();
|
|
}}
|
|
className={cn(item, idle)}
|
|
>
|
|
{icon(<GearIcon className="size-6" />, false, "settings")}
|
|
{label(t("tab.settings"), false)}
|
|
</button>
|
|
</nav>
|
|
);
|
|
}
|