Skip to content

Commit 6e67d9b

Browse files
committed
Fix mobile connection banner layout
1 parent 47ccc00 commit 6e67d9b

3 files changed

Lines changed: 114 additions & 4 deletions

File tree

packages/web/src/shells/shared/connection-status-banner.test.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { activationReasonAtom, activationStatusAtom } from "../../atoms/activati
55
import { connectionStatusAtom, lastReconnectAttemptAtom } from "../../atoms/connection";
66
import { ConnectionStatusBanner } from "./connection-status-banner";
77

8+
const viewportMocks = vi.hoisted(() => ({
9+
value: "desktop" as "desktop" | "mobile",
10+
}));
11+
12+
vi.mock("../../components/ui/_internal/use-viewport", () => ({
13+
useViewport: () => viewportMocks.value,
14+
}));
15+
816
function renderBanner() {
917
const store = createStore();
1018

@@ -24,6 +32,7 @@ describe("ConnectionStatusBanner", () => {
2432

2533
afterEach(() => {
2634
vi.useRealTimers();
35+
viewportMocks.value = "desktop";
2736
});
2837

2938
it("renders the unified reconnect message while reconnecting", () => {
@@ -36,6 +45,18 @@ describe("ConnectionStatusBanner", () => {
3645
expect(screen.getByText("连接已断开,正在重新连接...")).toBeInTheDocument();
3746
});
3847

48+
it("uses the shared mobile banner layout while reconnecting", () => {
49+
viewportMocks.value = "mobile";
50+
const store = renderBanner();
51+
52+
act(() => {
53+
store.set(connectionStatusAtom, "reconnecting");
54+
});
55+
56+
expect(screen.getByRole("status")).toHaveClass("connection-banner--mobile");
57+
expect(screen.getByRole("status")).not.toHaveClass("connection-banner--stacked");
58+
});
59+
3960
it("shows the displaced-session message instead of reconnecting when activation is gated", () => {
4061
const store = renderBanner();
4162

@@ -49,6 +70,21 @@ describe("ConnectionStatusBanner", () => {
4970
expect(screen.queryByText("连接已断开,正在重新连接...")).not.toBeInTheDocument();
5071
});
5172

73+
it("uses the shared mobile banner layout for displaced-session state", () => {
74+
viewportMocks.value = "mobile";
75+
const store = renderBanner();
76+
77+
act(() => {
78+
store.set(activationStatusAtom, "gated");
79+
store.set(activationReasonAtom, "displaced");
80+
store.set(connectionStatusAtom, "disconnected");
81+
});
82+
83+
expect(screen.getByRole("status")).toHaveClass("connection-banner--mobile");
84+
expect(screen.getByRole("status")).toHaveClass("connection-banner--error");
85+
expect(screen.getByText("另一个标签页已激活")).toBeInTheDocument();
86+
});
87+
5288
it("shows the slow recovery hint after 25 seconds", () => {
5389
const startedAt = new Date("2026-05-14T00:00:00.000Z").getTime();
5490
vi.setSystemTime(startedAt + 25_000);
@@ -101,4 +137,23 @@ describe("ConnectionStatusBanner", () => {
101137
screen.queryByText("连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。")
102138
).not.toBeInTheDocument();
103139
});
140+
141+
it("stacks the slow recovery hint and uses compact copy on mobile", () => {
142+
viewportMocks.value = "mobile";
143+
const startedAt = new Date("2026-05-14T00:00:00.000Z").getTime();
144+
vi.setSystemTime(startedAt + 25_000);
145+
const store = renderBanner();
146+
147+
act(() => {
148+
store.set(connectionStatusAtom, "reconnecting");
149+
store.set(lastReconnectAttemptAtom, startedAt);
150+
});
151+
152+
expect(screen.getByRole("status")).toHaveClass("connection-banner--mobile");
153+
expect(screen.getByRole("status")).toHaveClass("connection-banner--stacked");
154+
expect(screen.getByText("连接恢复较慢,长时间未恢复可刷新页面。")).toBeInTheDocument();
155+
expect(
156+
screen.queryByText("连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。")
157+
).not.toBeInTheDocument();
158+
});
104159
});

packages/web/src/shells/shared/connection-status-banner.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@ import { useAtomValue } from "jotai";
22
import { useEffect, useState } from "react";
33
import { activationReasonAtom, activationStatusAtom } from "../../atoms/activation";
44
import { connectionStatusAtom, lastReconnectAttemptAtom } from "../../atoms/connection";
5+
import { useViewport } from "../../components/ui/_internal/use-viewport";
56

67
const SLOW_RECOVERY_HINT_MS = 25_000;
8+
const SLOW_RECOVERY_HINT_TEXT = "连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。";
9+
const SLOW_RECOVERY_HINT_TEXT_MOBILE = "连接恢复较慢,长时间未恢复可刷新页面。";
710

811
export function ConnectionStatusBanner() {
912
const activationStatus = useAtomValue(activationStatusAtom);
1013
const activationReason = useAtomValue(activationReasonAtom);
1114
const connectionStatus = useAtomValue(connectionStatusAtom);
1215
const lastReconnectAttempt = useAtomValue(lastReconnectAttemptAtom);
16+
const viewport = useViewport();
17+
const isMobile = viewport === "mobile";
1318
const [now, setNow] = useState(() => Date.now());
1419

1520
useEffect(() => {
@@ -44,7 +49,11 @@ export function ConnectionStatusBanner() {
4449
(activationStatus === "gated" && activationReason === "displaced")
4550
) {
4651
return (
47-
<div className="connection-banner connection-banner--error" role="status" aria-live="polite">
52+
<div
53+
className={`connection-banner${isMobile ? " connection-banner--mobile" : ""} connection-banner--error`}
54+
role="status"
55+
aria-live="polite"
56+
>
4857
<span>另一个标签页已激活</span>
4958
</div>
5059
);
@@ -54,12 +63,21 @@ export function ConnectionStatusBanner() {
5463
lastReconnectAttempt !== null &&
5564
now - lastReconnectAttempt >= SLOW_RECOVERY_HINT_MS &&
5665
(connectionStatus === "reconnecting" || connectionStatus === "disconnected");
66+
const stacked = showSlowRecoveryHint && isMobile;
67+
const slowRecoveryHintText = isMobile ? SLOW_RECOVERY_HINT_TEXT_MOBILE : SLOW_RECOVERY_HINT_TEXT;
68+
const className = [
69+
"connection-banner",
70+
isMobile ? "connection-banner--mobile" : null,
71+
stacked ? "connection-banner--stacked" : null,
72+
]
73+
.filter(Boolean)
74+
.join(" ");
5775

5876
return (
59-
<div className="connection-banner" role="status" aria-live="polite">
60-
<span>连接已断开,正在重新连接...</span>
77+
<div className={className} role="status" aria-live="polite">
78+
<span className="connection-banner__primary">连接已断开,正在重新连接...</span>
6179
{showSlowRecoveryHint ? (
62-
<span>连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。</span>
80+
<span className="connection-banner__hint">{slowRecoveryHintText}</span>
6381
) : null}
6482
</div>
6583
);

packages/web/src/styles/base.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,8 @@ textarea::placeholder {
660660
display: flex;
661661
align-items: center;
662662
justify-content: center;
663+
gap: var(--sp-2);
664+
padding: 0 var(--sp-4);
663665
background: var(--accent-amber);
664666
color: var(--text-inverse);
665667
font-size: var(--type-label-size);
@@ -668,6 +670,41 @@ textarea::placeholder {
668670
z-index: var(--z-toast);
669671
}
670672

673+
.connection-banner__primary,
674+
.connection-banner__hint {
675+
min-width: 0;
676+
}
677+
678+
.connection-banner__hint {
679+
opacity: 0.88;
680+
}
681+
682+
.connection-banner--mobile {
683+
min-height: calc(var(--sp-6) + var(--mobile-safe-top));
684+
padding-top: var(--mobile-safe-top);
685+
padding-right: calc(var(--mobile-safe-right) + var(--sp-3));
686+
padding-left: calc(var(--mobile-safe-left) + var(--sp-3));
687+
}
688+
689+
.connection-banner--stacked {
690+
height: auto;
691+
flex-direction: column;
692+
align-items: flex-start;
693+
justify-content: center;
694+
gap: 2px;
695+
padding-top: calc(var(--mobile-safe-top) + var(--sp-1));
696+
padding-right: calc(var(--mobile-safe-right) + var(--sp-3));
697+
padding-bottom: var(--sp-1);
698+
padding-left: calc(var(--mobile-safe-left) + var(--sp-3));
699+
}
700+
701+
.connection-banner--stacked .connection-banner__primary,
702+
.connection-banner--stacked .connection-banner__hint {
703+
width: 100%;
704+
text-align: left;
705+
overflow-wrap: anywhere;
706+
}
707+
671708
.connection-banner--error {
672709
background: var(--color-error);
673710
}

0 commit comments

Comments
 (0)