mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 23:31:30 +00:00
Fix channels page shifting and dropped bottom-nav taps on mobile (#146)
This commit is contained in:
1 parent
52d633d173
commit
dc757cfb51
6 files changed
+95
-17
No files matched your search
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BottomSheet } from "./BottomSheet";
|
||||
import { ENABLED_TABS } from "../lib/constants";
|
||||
import { touchClick } from "../lib/touch-click";
|
||||
|
||||
// Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet.
|
||||
const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const;
|
||||
@@ -62,6 +63,7 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo
|
||||
ariaHasPopup?: "menu";
|
||||
ariaExpanded?: boolean;
|
||||
}) {
|
||||
const tap = touchClick(onClick);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -69,7 +71,7 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo
|
||||
aria-selected={ariaSelected}
|
||||
aria-haspopup={ariaHasPopup}
|
||||
aria-expanded={ariaExpanded}
|
||||
onClick={onClick}
|
||||
{...tap}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-1.5 text-[10px] font-medium tracking-wide cursor-pointer transition-colors ${
|
||||
active ? "text-primary" : "text-text-muted hover:text-text-normal"
|
||||
}`}
|
||||
@@ -80,23 +82,29 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo
|
||||
);
|
||||
}
|
||||
|
||||
function SheetItem({ label, active, onPick }: { label: string; active: boolean; onPick: () => void }) {
|
||||
const tap = touchClick(onPick);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
{...tap}
|
||||
className={`w-full flex items-center gap-3 px-5 py-3 text-left text-sm font-medium cursor-pointer transition-colors ${
|
||||
active ? "text-primary" : "text-text-normal hover:bg-text-normal/3"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Bottom sheet listing the overflow tabs.
|
||||
function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activeTab: string; onPick: (tab: string) => void; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<BottomSheet onClose={onClose} role="menu" label={t("navigation.moreTabs")}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onPick(tab)}
|
||||
className={`w-full flex items-center gap-3 px-5 py-3 text-left text-sm font-medium cursor-pointer transition-colors ${
|
||||
activeTab === tab ? "text-primary" : "text-text-normal hover:bg-text-normal/3"
|
||||
}`}
|
||||
>
|
||||
{t(`tabs.${tab}`, { defaultValue: tab })}
|
||||
</button>
|
||||
<SheetItem key={tab} label={t(`tabs.${tab}`, { defaultValue: tab })} active={activeTab === tab} onPick={() => onPick(tab)} />
|
||||
))}
|
||||
</BottomSheet>
|
||||
);
|
||||
|
||||
@@ -94,7 +94,6 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
|
||||
[messages],
|
||||
);
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const [userScrolled, setUserScrolled] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const viewKey = `${channel?.id ?? ""}:${scope}`;
|
||||
@@ -136,7 +135,8 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
|
||||
}
|
||||
|
||||
if (sorted.length > anchor.count && !userScrolled) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); // live message arrived; follow it down
|
||||
// live message arrived; follow it down. Not scrollIntoView — that also scrolls the page shell
|
||||
el?.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
anchor.count = sorted.length;
|
||||
}, [sorted.length, viewKey, isLoading, userScrolled]);
|
||||
@@ -221,7 +221,6 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
|
||||
{sorted.map((msg) => (
|
||||
<MessageRow key={msg.packetHash || msg.id} msg={msg} heardCount={heardCounts[msg.packetHash]} onAnalyze={onAnalyze} />
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-32 text-text-muted text-xs font-mono">
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TouchEvent } from "react";
|
||||
|
||||
// iOS Safari drops a tap's click when new tappable content appears mid-tap (the live packet list
|
||||
// does that constantly), so a touch acts on touchend and cancels the click iOS might still send.
|
||||
export function touchClick(onActivate: () => void) {
|
||||
return {
|
||||
onClick: onActivate,
|
||||
onTouchEnd: (e: TouchEvent<HTMLElement>) => {
|
||||
const touch = e.changedTouches[0];
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
if (!touch || touch.clientX < r.left || touch.clientX > r.right || touch.clientY < r.top || touch.clientY > r.bottom) return;
|
||||
e.preventDefault();
|
||||
onActivate();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -41,4 +41,32 @@ describe("BottomNav", () => {
|
||||
const more = screen.getByText("More").closest("button")!;
|
||||
expect(more.className).toContain("text-primary");
|
||||
});
|
||||
|
||||
// iOS drops the click when live content lands mid-tap, so a touch acts on touchend alone
|
||||
const tapEnd = (el: Element, x = 0, y = 0) => fireEvent.touchEnd(el, { changedTouches: [{ clientX: x, clientY: y }] });
|
||||
|
||||
it("switches tab on touchend and cancels the trailing click", () => {
|
||||
const onTabChange = vi.fn();
|
||||
render(<BottomNav activeTab="Packets" onTabChange={onTabChange} />);
|
||||
const notPrevented = tapEnd(screen.getByRole("tab", { name: "Channels" }));
|
||||
expect(onTabChange).toHaveBeenCalledTimes(1);
|
||||
expect(onTabChange).toHaveBeenCalledWith("Channels");
|
||||
expect(notPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a touch that lifts off the button", () => {
|
||||
const onTabChange = vi.fn();
|
||||
render(<BottomNav activeTab="Packets" onTabChange={onTabChange} />);
|
||||
tapEnd(screen.getByRole("tab", { name: "Channels" }), 50, 50);
|
||||
expect(onTabChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens More and picks an overflow tab by touch", () => {
|
||||
const onTabChange = vi.fn();
|
||||
render(<BottomNav activeTab="Packets" onTabChange={onTabChange} />);
|
||||
tapEnd(screen.getByText("More").closest("button")!);
|
||||
tapEnd(screen.getByRole("menuitem", { name: "Routes" }));
|
||||
expect(onTabChange).toHaveBeenCalledWith("Routes");
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getChannelMessagesPage).mockResolvedValue(page(messages));
|
||||
vi.mocked(getChannels).mockResolvedValue({ items: [channel], hasMore: false, nextCursor: null });
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
Element.prototype.scrollTo = vi.fn();
|
||||
});
|
||||
|
||||
describe("channel scope evidence", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MessagePanel } from "../../../src/features/channels/MessagePanel";
|
||||
import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types";
|
||||
@@ -121,3 +121,30 @@ describe("MessagePanel heard badge", () => {
|
||||
expect(screen.getByTitle("Entendu 1 fois")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MessagePanel live follow", () => {
|
||||
it("scrolls only its own list when a live message arrives, not the page", async () => {
|
||||
const scrollTo = vi.fn();
|
||||
window.HTMLElement.prototype.scrollTo = scrollTo as unknown as typeof HTMLElement.prototype.scrollTo;
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MessagePanel channel={channel} heardCounts={{}} regionKey="*" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("live two");
|
||||
|
||||
const live = { ...liveMsgB, packetHash: "ph-live-c", content: "live three", sentAt: 5000 } as ChannelMessage;
|
||||
act(() => {
|
||||
qc.setQueryData(["channel-messages", channel.id, "*", ""], (old: { pages: { items: ChannelMessage[] }[] }) => ({
|
||||
...old,
|
||||
pages: [{ ...old.pages[0], items: [live, ...old.pages[0].items] }, ...old.pages.slice(1)],
|
||||
}));
|
||||
});
|
||||
await screen.findByText("live three");
|
||||
|
||||
// scrollIntoView also scrolls every ancestor up to the document, shoving the whole app shell up
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user