Skip to content

Commit dfadabe

Browse files
Keep phone toasts, the keyboard, and Ask clear of the tab bar
The toast offset now includes the bar up to 767px. The keyboard listener runs only below 768px, ignores pinch-zoom, and Ask sizes itself from the chrome above the page so a banner cannot push the composer under the bar. Co-authored-by: Gáborjáni-Szabó Ádám <Programcode@users.noreply.github.com>
1 parent 7f5efdf commit dfadabe

8 files changed

Lines changed: 289 additions & 26 deletions

File tree

‎e2e/home-navigation.spec.ts‎

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect, type Page } from '@playwright/test';
22
import { setLocale, stubSupabaseSession } from './helpers/auth';
33

44
test.beforeEach(async ({ page }) => {
@@ -173,6 +173,133 @@ test('phone tab bar holds Tools, Current debate, Library, and Settings', async (
173173
expect(termsBox!.y + termsBox!.height).toBeLessThanOrEqual(tabBox!.y + 1);
174174
});
175175

176+
async function paintViewport(page: Page, next: { height: number; offsetTop: number; scale: number }) {
177+
await page.evaluate((value) => {
178+
const viewport = window.visualViewport;
179+
if (!viewport) throw new Error('visualViewport is missing');
180+
for (const key of ['height', 'offsetTop', 'scale'] as const) {
181+
Object.defineProperty(viewport, key, { configurable: true, get: () => value[key] });
182+
}
183+
viewport.dispatchEvent(new Event('resize'));
184+
}, next);
185+
}
186+
187+
test('phone tab bar hides for a keyboard and returns, and ignores pinch-zoom', async ({ page }) => {
188+
await page.setViewportSize({ width: 390, height: 844 });
189+
await page.goto('/app/ask', { waitUntil: 'domcontentloaded' });
190+
const field = page.getByRole('textbox', { name: 'Message to your debate coach' });
191+
const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' });
192+
await expect(field).toBeVisible({ timeout: 15_000 });
193+
await expect(tabBar).toBeVisible();
194+
195+
await field.focus();
196+
await paintViewport(page, { height: 380, offsetTop: 0, scale: 1.5 });
197+
await expect(tabBar).toBeVisible();
198+
199+
await paintViewport(page, { height: 380, offsetTop: 0, scale: 1 });
200+
await expect(tabBar).toBeHidden();
201+
const hidden = await page.evaluate(() => ({
202+
reserve: document.documentElement.style.getPropertyValue('--phone-tab-reserve').trim(),
203+
keyboard: document.documentElement.style.getPropertyValue('--native-keyboard-height').trim(),
204+
}));
205+
expect(hidden.reserve).toBe('0px');
206+
expect(Number.parseFloat(hidden.keyboard)).toBeGreaterThan(120);
207+
208+
await paintViewport(page, { height: 844, offsetTop: 0, scale: 1 });
209+
await expect(tabBar).toBeVisible();
210+
const shown = await page.evaluate(() => document.documentElement.style.getPropertyValue('--phone-tab-reserve').trim());
211+
expect(Number.parseFloat(shown)).toBeGreaterThan(0);
212+
});
213+
214+
test('Ask composer stays above the tab bar while an announcement is up', async ({ page }) => {
215+
await page.route('**/functions/v1/announcements*', (route) => route.fulfill({
216+
json: {
217+
announcements: [{
218+
id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
219+
title: 'Scheduled maintenance',
220+
body: 'DebateLab will be unavailable for ten minutes.',
221+
starts_at: '2026-08-31T12:00:00.000Z',
222+
expires_at: null,
223+
created_at: '2026-08-31T11:00:00.000Z',
224+
}],
225+
},
226+
}));
227+
await page.setViewportSize({ width: 390, height: 844 });
228+
await page.goto('/app/ask', { waitUntil: 'domcontentloaded' });
229+
await expect(page.getByRole('heading', { name: 'Scheduled maintenance' })).toBeVisible({ timeout: 15_000 });
230+
const send = page.getByRole('button', { name: 'Send' });
231+
const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' });
232+
await expect(send).toBeVisible();
233+
await expect(tabBar).toBeVisible();
234+
const [sendBox, tabBox, tops] = await Promise.all([
235+
send.boundingBox(),
236+
tabBar.boundingBox(),
237+
page.evaluate(() => ({
238+
main: document.documentElement.style.getPropertyValue('--app-main-top').trim(),
239+
header: document.documentElement.style.getPropertyValue('--app-header-bottom').trim(),
240+
})),
241+
]);
242+
expect(sendBox).not.toBeNull();
243+
expect(tabBox).not.toBeNull();
244+
expect(sendBox!.y + sendBox!.height).toBeLessThanOrEqual(tabBox!.y + 1);
245+
expect(Number.parseFloat(tops.main)).toBeGreaterThan(Number.parseFloat(tops.header));
246+
});
247+
248+
test('a toast at 700px sits above the tab bar', async ({ page }) => {
249+
await page.setViewportSize({ width: 700, height: 800 });
250+
await page.goto('/app', { waitUntil: 'domcontentloaded' });
251+
await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 });
252+
const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' });
253+
await expect(tabBar).toBeVisible();
254+
255+
const toasted = await page.evaluate(async () => {
256+
const match = performance.getEntriesByType('resource')
257+
.map((entry) => entry.name)
258+
.find((name) => name.includes('/sonner'));
259+
if (!match) return 'missing';
260+
const mod = await import(/* @vite-ignore */ match) as { toast?: { success: (message: string, options: { duration: number }) => void } };
261+
if (!mod.toast?.success) return 'no-toast';
262+
mod.toast.success('Copied to clipboard', { duration: 60_000 });
263+
return 'ok';
264+
});
265+
expect(toasted).toBe('ok');
266+
267+
const toast = page.locator('[data-sonner-toast]').last();
268+
await expect(toast).toHaveAttribute('data-mounted', 'true');
269+
const offset = await page.evaluate(() => {
270+
const ol = document.querySelector('[data-sonner-toaster]');
271+
return {
272+
reserve: Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--phone-tab-reserve')),
273+
usedBottom: ol ? Number.parseFloat(getComputedStyle(ol).bottom) : 0,
274+
};
275+
});
276+
expect(offset.reserve).toBeGreaterThan(40);
277+
expect(offset.usedBottom).toBeGreaterThan(offset.reserve);
278+
// The enter slide starts under the offset. The resting box is what has to clear the bar.
279+
await expect.poll(async () => {
280+
const [toastBox, tabBox] = await Promise.all([toast.boundingBox(), tabBar.boundingBox()]);
281+
if (!toastBox || !tabBox) return Number.POSITIVE_INFINITY;
282+
return toastBox.y + toastBox.height - tabBox.y;
283+
}).toBeLessThanOrEqual(1);
284+
});
285+
286+
test('iPad width does not keep a keyboard pad', async ({ page }) => {
287+
await page.setViewportSize({ width: 820, height: 1180 });
288+
await page.goto('/app/ask', { waitUntil: 'domcontentloaded' });
289+
const field = page.getByRole('textbox', { name: 'Message to your debate coach' });
290+
await expect(field).toBeVisible({ timeout: 15_000 });
291+
await field.focus();
292+
await paintViewport(page, { height: 500, offsetTop: 0, scale: 1 });
293+
const pad = await page.evaluate(() => ({
294+
keyboard: document.documentElement.style.getPropertyValue('--native-keyboard-height').trim(),
295+
reserve: document.documentElement.style.getPropertyValue('--phone-tab-reserve').trim(),
296+
main: getComputedStyle(document.querySelector('main')!).paddingBottom,
297+
}));
298+
expect(pad.keyboard).toBe('');
299+
expect(pad.reserve).toBe('');
300+
expect(pad.main).toBe('0px');
301+
});
302+
176303
test('iPad portrait keeps the header menu instead of the tab bar', async ({ page }) => {
177304
await page.setViewportSize({ width: 820, height: 1180 });
178305
await page.goto('/app', { waitUntil: 'domcontentloaded' });

‎src/components/layout/AppLayout.tsx‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export function AppLayout() {
108108
const dismissedAnnouncementIds = useRef(new Set<string>());
109109
const mobileMenuTriggerRef = useRef<HTMLButtonElement>(null);
110110
const headerRef = useRef<HTMLElement>(null);
111+
const aboveMainRef = useRef<HTMLDivElement>(null);
111112
const activeRequestRef = useRef<AbortController | null>(null);
112113
const lastRequestAtRef = useRef<number | null>(null);
113114
const activeUserIdRef = useRef<string | null>(null);
@@ -236,6 +237,27 @@ export function AppLayout() {
236237
};
237238
}, []);
238239

240+
// Ask sizes itself from this, so a banner between the header and main is included.
241+
// The value is the document offset of main, measured when the chrome above it changes size.
242+
useEffect(() => {
243+
const chrome = aboveMainRef.current;
244+
if (!chrome || typeof ResizeObserver === 'undefined') return;
245+
const root = document.documentElement;
246+
const apply = () => {
247+
const top = Math.max(0, Math.round(chrome.getBoundingClientRect().bottom + window.scrollY));
248+
root.style.setProperty('--app-main-top', `${top}px`);
249+
};
250+
apply();
251+
const observer = new ResizeObserver(apply);
252+
observer.observe(chrome);
253+
window.addEventListener('resize', apply);
254+
return () => {
255+
observer.disconnect();
256+
window.removeEventListener('resize', apply);
257+
root.style.removeProperty('--app-main-top');
258+
};
259+
}, []);
260+
239261
// Dismiss is optimistic: the banner goes at once and comes back only if the server refuses.
240262
const handleDismissAnnouncement = async (announcementId: string) => {
241263
const requestUserId = userId;
@@ -266,6 +288,7 @@ export function AppLayout() {
266288

267289
return (
268290
<div className="native-safe-screen flex flex-col bg-background">
291+
<div ref={aboveMainRef}>
269292
<SkipLink />
270293
{/* Header: the edge appears once content scrolls under it. */}
271294
<header
@@ -387,8 +410,6 @@ export function AppLayout() {
387410
</div>
388411
</header>
389412

390-
<PhoneTabBar />
391-
392413
{announcementsUserId === userId && announcements[0] && (
393414
<AnnouncementBanner
394415
announcement={announcements[0]}
@@ -402,6 +423,7 @@ export function AppLayout() {
402423

403424
<DebateDialogs />
404425
<InactiveDebateGate />
426+
</div>
405427

406428
{/* Main Content */}
407429
<main id={MAIN_CONTENT_ID} className="native-main-content flex-1 focus-visible:outline-hidden">
@@ -418,6 +440,9 @@ export function AppLayout() {
418440
</div>
419441
</div>
420442
</footer>
443+
444+
{/* After main so keyboard order follows the page, then the bar. Fixed, so the flow is unchanged. */}
445+
<PhoneTabBar />
421446
</div>
422447
);
423448
}

‎src/components/layout/PhoneTabBar.tsx‎

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import { cn } from '@/lib/utils';
66
import { useRoutePrefetch } from '@/hooks/use-route-prefetch';
77
import { useCurrentDebate } from '@/lib/stores/debate-store';
88
import { navSectionFor } from '@/components/layout/nav-sections';
9-
import { KEYBOARD_OPEN_PX, keyboardOverlap } from '@/components/layout/phone-tab-bar';
9+
import { MAIN_CONTENT_ID } from '@/components/layout/SkipLink';
10+
import { PHONE_TAB_MEDIA, isTextEntry, keyboardIsOpen, keyboardOverlap } from '@/components/layout/phone-tab-bar';
1011

1112
// Below `md` (768px) only. iPad portrait is 820px and keeps the header menu:
1213
// four tabs would drop History and repeat the Current debate row already in the header.
1314
const tabClass = cn(
1415
'flex min-h-14 min-w-0 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-lg px-1 py-1.5',
1516
'text-xs font-bold leading-tight text-muted-foreground transition-[color,background-color,transform] duration-150 active:scale-[0.97]',
17+
'motion-reduce:transition-none motion-reduce:active:scale-100',
1618
);
1719
const tabActiveClass = 'bg-brand-tint text-primary';
1820

@@ -54,6 +56,23 @@ function PhoneTab({
5456
);
5557
}
5658

59+
function textEntryFocused() {
60+
const active = document.activeElement;
61+
if (!(active instanceof HTMLElement)) return false;
62+
const inputType = active instanceof HTMLInputElement ? active.type : null;
63+
return isTextEntry(active.tagName, inputType, active.isContentEditable);
64+
}
65+
66+
function moveFocusToMain() {
67+
const main = document.getElementById(MAIN_CONTENT_ID);
68+
if (!(main instanceof HTMLElement)) return;
69+
if (!main.hasAttribute('tabindex')) {
70+
main.setAttribute('tabindex', '-1');
71+
main.addEventListener('blur', () => main.removeAttribute('tabindex'), { once: true });
72+
}
73+
main.focus({ preventScroll: true });
74+
}
75+
5776
export function PhoneTabBar() {
5877
const { t } = useTranslation();
5978
const location = useLocation();
@@ -64,45 +83,85 @@ export function PhoneTabBar() {
6483
const [keyboardOpen, setKeyboardOpen] = useState(false);
6584
const [keyboardPx, setKeyboardPx] = useState(0);
6685

86+
// The listener exists only while the tab bar does. Wider layouts never write
87+
// --native-keyboard-height, so main does not grow a keyboard pad there.
6788
useEffect(() => {
89+
const phone = window.matchMedia(PHONE_TAB_MEDIA);
6890
const viewport = window.visualViewport;
69-
if (!viewport) return;
91+
let listening = false;
92+
7093
const update = () => {
71-
const overlap = keyboardOverlap(window.innerHeight, viewport.height, viewport.offsetTop);
72-
const open = overlap >= KEYBOARD_OPEN_PX;
94+
if (!phone.matches || !viewport) return;
95+
const open = keyboardIsOpen(
96+
window.innerHeight,
97+
viewport.height,
98+
viewport.offsetTop,
99+
viewport.scale,
100+
textEntryFocused(),
101+
);
102+
if (open && navRef.current?.contains(document.activeElement)) moveFocusToMain();
103+
const overlap = open ? keyboardOverlap(window.innerHeight, viewport.height, viewport.offsetTop) : 0;
73104
setKeyboardOpen((current) => (current === open ? current : open));
74-
setKeyboardPx((current) => (current === (open ? overlap : 0) ? current : (open ? overlap : 0)));
105+
setKeyboardPx((current) => (current === overlap ? current : overlap));
75106
};
76-
update();
77-
viewport.addEventListener('resize', update);
78-
viewport.addEventListener('scroll', update);
79-
return () => {
107+
const listen = () => {
108+
if (!viewport || listening) return;
109+
listening = true;
110+
viewport.addEventListener('resize', update);
111+
viewport.addEventListener('scroll', update);
112+
document.addEventListener('focusin', update);
113+
document.addEventListener('focusout', update);
114+
};
115+
const silence = () => {
116+
if (!viewport || !listening) return;
117+
listening = false;
80118
viewport.removeEventListener('resize', update);
81119
viewport.removeEventListener('scroll', update);
120+
document.removeEventListener('focusin', update);
121+
document.removeEventListener('focusout', update);
122+
};
123+
const sync = () => {
124+
if (!phone.matches) {
125+
silence();
126+
setKeyboardOpen(false);
127+
setKeyboardPx(0);
128+
return;
129+
}
130+
listen();
131+
update();
132+
};
133+
sync();
134+
phone.addEventListener('change', sync);
135+
return () => {
136+
silence();
137+
phone.removeEventListener('change', sync);
82138
};
83139
}, []);
84140

85141
// The bar's real height, including the home-indicator inset, is what the page
86142
// pads by. A keyboard hides the bar, so the focused field can use that space.
143+
// At `md` and up both variables are removed, including a value left by a resize.
87144
useLayoutEffect(() => {
88145
const root = document.documentElement;
146+
const phone = window.matchMedia(PHONE_TAB_MEDIA);
89147
const nav = navRef.current;
90148
const apply = () => {
149+
if (!phone.matches) {
150+
root.style.removeProperty('--phone-tab-reserve');
151+
root.style.removeProperty('--native-keyboard-height');
152+
return;
153+
}
91154
const reserve = !nav || keyboardOpen ? 0 : Math.round(nav.getBoundingClientRect().height);
92155
root.style.setProperty('--phone-tab-reserve', `${reserve}px`);
93156
root.style.setProperty('--native-keyboard-height', `${keyboardOpen ? keyboardPx : 0}px`);
94157
};
95158
apply();
96-
if (!nav || typeof ResizeObserver === 'undefined') {
97-
return () => {
98-
root.style.removeProperty('--phone-tab-reserve');
99-
root.style.removeProperty('--native-keyboard-height');
100-
};
101-
}
102-
const observer = new ResizeObserver(() => apply());
103-
observer.observe(nav);
159+
const observer = nav && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(apply) : null;
160+
if (nav && observer) observer.observe(nav);
161+
phone.addEventListener('change', apply);
104162
return () => {
105-
observer.disconnect();
163+
observer?.disconnect();
164+
phone.removeEventListener('change', apply);
106165
root.style.removeProperty('--phone-tab-reserve');
107166
root.style.removeProperty('--native-keyboard-height');
108167
};
@@ -119,6 +178,7 @@ export function PhoneTabBar() {
119178
)}
120179
>
121180
{tabs.map((tab) => {
181+
// History, billing, and admin are not tabs, so none of these is current there.
122182
const active = tab.key === 'tools' ? section === 'tools'
123183
: tab.key === 'currentDebate' ? section === 'debate'
124184
: path === tab.to;

‎src/components/layout/phone-tab-bar.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from 'vitest';
2-
import { KEYBOARD_OPEN_PX, keyboardOverlap } from './phone-tab-bar';
2+
import { KEYBOARD_OPEN_PX, isTextEntry, keyboardIsOpen, keyboardOverlap } from './phone-tab-bar';
33

44
test('keyboard overlap ignores the URL bar and reports a real keyboard', () => {
55
expect(keyboardOverlap(844, 800, 0)).toBe(44);
@@ -15,3 +15,20 @@ test('keyboard overlap stays at zero for a missing or inverted viewport', () =>
1515
expect(keyboardOverlap(800, 900, 0)).toBe(0);
1616
expect(keyboardOverlap(800, 700, 200)).toBe(0);
1717
});
18+
19+
test('a keyboard is not pinch-zoom or an unfocused viewport shrink', () => {
20+
expect(keyboardIsOpen(844, 430, 0, 1, true)).toBe(true);
21+
expect(keyboardIsOpen(844, 800, 0, 1, true)).toBe(false);
22+
expect(keyboardIsOpen(844, 430, 0, 1.5, true)).toBe(false);
23+
expect(keyboardIsOpen(844, 430, 0, 1, false)).toBe(false);
24+
expect(keyboardIsOpen(844, 430, 0, Number.NaN, true)).toBe(false);
25+
});
26+
27+
test('text entry is an input or textarea, not a button', () => {
28+
expect(isTextEntry('TEXTAREA', null, false)).toBe(true);
29+
expect(isTextEntry('INPUT', 'text', false)).toBe(true);
30+
expect(isTextEntry('INPUT', null, false)).toBe(true);
31+
expect(isTextEntry('DIV', null, true)).toBe(true);
32+
expect(isTextEntry('INPUT', 'checkbox', false)).toBe(false);
33+
expect(isTextEntry('BUTTON', null, false)).toBe(false);
34+
});

0 commit comments

Comments
 (0)