mirror of
https://github.com/vernu/textbee.git
synced 2026-08-02 18:38:41 -04:00
fix: day header overlap, mobile footer alignment, modal animation
Day headers in message history could cover message rows. Two distinct causes, both reproduced before fixing: - On mobile the header was pinned to top-14, the same sticky band the mobile search bar already occupies, so it detached and landed on top of rows. Being bg-muted/70 with a backdrop blur, row text bled through it, which is the distortion that was reported. - On desktop it was pinned to top-0, behind the app header, and it covered rows scrolled beneath it and swallowed their clicks. Playwright surfaced this second one: a row click failed with "h3 intercepts pointer events". Day headers are no longer sticky. A page holds 20 messages, so groups are short and pinning bought little in exchange for those failures. Footer links are left-aligned on mobile. Centred links in a single column had no common edge to scan down. Modal animation is now a quick fade. Dialogs slid in from the left and top while zooming, which is the movement that read badly; the slide and zoom are gone and the duration drops from 200ms to 150ms, applied to both the dialog and alert-dialog primitives so every modal matches. Sheets still slide, since that is what a drawer should do. Guards: the overlap check runs at both mobile and desktop widths and also asserts a row is still clickable after scrolling. Verified it fails against the old markup rather than assuming a green test means coverage. Fixture dates now anchor to local midnight instead of "N hours ago". A 2-hour-old message falls on the previous day when the suite runs shortly after midnight, which made the Today/Yesterday assertions depend on the wall clock. It failed exactly that way during this session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -208,7 +208,13 @@ export default function MessageHistory() {
|
||||
<div className='overflow-hidden rounded-xl border border-border'>
|
||||
{days.map((day) => (
|
||||
<section key={day.key}>
|
||||
<h3 className='sticky top-14 z-10 border-b border-border bg-muted/70 px-3 py-1.5 text-xs font-medium text-muted-foreground backdrop-blur md:top-0'>
|
||||
{/* Deliberately not sticky. On mobile it pinned to the same
|
||||
offset as the sticky search bar and landed on top of message
|
||||
rows, translucent, with text bleeding through. On desktop it
|
||||
covered rows scrolled beneath it and swallowed their clicks.
|
||||
A page holds 20 messages, so groups are short and a pinned
|
||||
header bought little in exchange for that. */}
|
||||
<h3 className='border-b border-border bg-muted px-3 py-1.5 text-xs font-medium text-muted-foreground'>
|
||||
{day.label}
|
||||
</h3>
|
||||
<div className='divide-y divide-border'>
|
||||
|
||||
@@ -22,7 +22,9 @@ const linkClass =
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className='border-t border-border bg-muted/30'>
|
||||
<div className='mx-auto flex max-w-7xl flex-col items-center gap-4 px-4 py-6 sm:px-6 md:flex-row md:justify-between lg:px-8'>
|
||||
{/* Left-aligned on mobile: centred links in a single column read as a
|
||||
ragged stack with no common edge to scan down. */}
|
||||
<div className='mx-auto flex max-w-7xl flex-col items-start gap-4 px-4 py-6 sm:items-center sm:px-6 md:flex-row md:justify-between lg:px-8'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Image
|
||||
src='/images/logo.png'
|
||||
@@ -38,7 +40,7 @@ export default function Footer() {
|
||||
|
||||
{/* Stacked on mobile: wrapped inline links produced a ragged two-line
|
||||
block that was hard to scan and gave small tap targets. */}
|
||||
<nav className='flex w-full flex-col items-center gap-3 sm:w-auto sm:flex-row sm:flex-wrap sm:justify-center sm:gap-x-5 sm:gap-y-2'>
|
||||
<nav className='flex w-full flex-col items-start gap-3 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-center sm:gap-x-5 sm:gap-y-2'>
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.label}
|
||||
|
||||
@@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-black/80 duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -15,6 +15,75 @@ test.describe('message history (mocked API, no real backend)', () => {
|
||||
await expect(page.getByText('Reply from a customer')).toBeVisible()
|
||||
})
|
||||
|
||||
// Checked at both widths: the mobile break was a header pinned to the same
|
||||
// offset as the search bar, and the desktop one was a header covering rows
|
||||
// and swallowing their clicks.
|
||||
for (const viewport of [
|
||||
{ name: 'mobile', width: 390, height: 700 },
|
||||
{ name: 'desktop', width: 1280, height: 800 },
|
||||
]) {
|
||||
test(`day headers never overlap message rows on ${viewport.name}`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.setViewportSize({
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
})
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
|
||||
// Enough messages across several days to force scrolling.
|
||||
const at = (days: number, hours: number) =>
|
||||
new Date(Date.now() - days * 86400000 - hours * 3600000).toISOString()
|
||||
await page.route('**/api/v1/gateway/devices/*/messages*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: Array.from({ length: 12 }).map((_, i) => ({
|
||||
_id: `m${i}`,
|
||||
sender: `+2519403617${40 + i}`,
|
||||
message: 'A message long enough to wrap onto a second line here',
|
||||
status: 'received',
|
||||
type: 'received',
|
||||
receivedAt: at(Math.floor(i / 3), i),
|
||||
createdAt: at(Math.floor(i / 3), i),
|
||||
})),
|
||||
meta: { total: 12, page: 1, limit: 20, totalPages: 1 },
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
await page.goto('/dashboard/messaging/history')
|
||||
await expect(page.getByRole('heading', { name: 'Today' })).toBeVisible()
|
||||
await page.evaluate(() => window.scrollTo(0, 420))
|
||||
|
||||
const headers = await page.locator('h3').all()
|
||||
const rows = await page.getByRole('button', { name: /\+2519/ }).all()
|
||||
|
||||
for (const header of headers) {
|
||||
const hb = await header.boundingBox()
|
||||
if (!hb) continue
|
||||
for (const row of rows) {
|
||||
const rb = await row.boundingBox()
|
||||
if (!rb) continue
|
||||
const overlap =
|
||||
Math.min(hb.y + hb.height, rb.y + rb.height) - Math.max(hb.y, rb.y)
|
||||
expect(
|
||||
overlap,
|
||||
'a day header must not cover a message row'
|
||||
).toBeLessThanOrEqual(1)
|
||||
}
|
||||
}
|
||||
|
||||
// A covering header also swallowed the row's click, so the row must
|
||||
// still be clickable after scrolling.
|
||||
await page.getByRole('button', { name: /\+2519/ }).first().click()
|
||||
await expect(page.getByRole('dialog')).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test('search is sent to the server, not applied to the loaded page', async ({
|
||||
page,
|
||||
context,
|
||||
|
||||
@@ -107,9 +107,18 @@ export const mockWebhookNotifications = { data: [], total: 0 }
|
||||
// The real endpoint populates `device` (select: _id brand model buildId
|
||||
// enabled), so the fixtures carry it too: replying reads message.device._id,
|
||||
// and without it the mocked path would not exercise what production does.
|
||||
// Dates are relative so the day-grouped list always has a "Today" section.
|
||||
const hoursAgo = (hours: number) =>
|
||||
new Date(Date.now() - hours * 60 * 60 * 1000).toISOString()
|
||||
// Anchored to local midnight rather than "N hours ago": a message 2 hours old
|
||||
// falls on the previous day when the suite runs just after midnight, which
|
||||
// made the Today/Yesterday grouping assertions depend on the wall clock.
|
||||
const startOfToday = () => {
|
||||
const d = new Date()
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d.getTime()
|
||||
}
|
||||
// Now is always today and never in the future.
|
||||
const todayIso = () => new Date().toISOString()
|
||||
// An hour before local midnight is always yesterday.
|
||||
const yesterdayIso = () => new Date(startOfToday() - 60 * 60 * 1000).toISOString()
|
||||
|
||||
export const mockMessages = {
|
||||
data: [
|
||||
@@ -120,8 +129,8 @@ export const mockMessages = {
|
||||
status: 'sent',
|
||||
type: 'sent',
|
||||
device: { _id: 'device_1', brand: 'Google', model: 'Pixel 8' },
|
||||
requestedAt: hoursAgo(2),
|
||||
createdAt: hoursAgo(2),
|
||||
requestedAt: todayIso(),
|
||||
createdAt: todayIso(),
|
||||
},
|
||||
{
|
||||
_id: 'msg_2',
|
||||
@@ -130,8 +139,8 @@ export const mockMessages = {
|
||||
status: 'received',
|
||||
type: 'received',
|
||||
device: { _id: 'device_1', brand: 'Google', model: 'Pixel 8' },
|
||||
receivedAt: hoursAgo(30),
|
||||
createdAt: hoursAgo(30),
|
||||
receivedAt: yesterdayIso(),
|
||||
createdAt: yesterdayIso(),
|
||||
},
|
||||
],
|
||||
meta: { total: 2, page: 1, limit: 20, totalPages: 1 },
|
||||
|
||||
Reference in New Issue
Block a user