From 40f8912937989085a277117685bb84c5b6f31602 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 19 Sep 2026 19:24:37 +0200 Subject: [PATCH] fix(collections): put the cover camera beside the close button The custom image control on a saved place's cover sat in the top left corner, on top of the category chip, and hid half of it. It also looked like nothing else on the cover: a square dark button next to a round translucent close button. The camera and the remove image button now sit in the top right corner next to the close button, camera directly left of the X, remove further left when there is a custom image, all three in the close button's round translucent look with its hover, phone size and focus ring. The phone sheet had the same overlap and gets the same row. The category chip and the controls share one header, so neither can run under the other: the controls hold the end and never shrink, and the chip ellipsizes a long category instead of sliding under the buttons in the narrow docked detail. On the phone the header is in flow, so a place without a category starts its name below the buttons. Picker, tooltip, labels, the spinner while an upload runs and the removal control behave as before. The trip planner inspector keeps its avatar overlay; it never had the overlap. --- .../CollectionPlaceDetail.test.tsx | 88 ++++++++++++++++++- .../Collections/CollectionPlaceDetail.tsx | 68 +++++++------- .../screens/collections/MCollPlaceSheet.tsx | 78 ++++++++-------- client/src/styles/collections.css | 5 +- .../collections/MCollPlaceSheet.test.tsx | 62 ++++++++++++- 5 files changed, 227 insertions(+), 74 deletions(-) diff --git a/client/src/components/Collections/CollectionPlaceDetail.test.tsx b/client/src/components/Collections/CollectionPlaceDetail.test.tsx index a4ea825f8..7b65ecf81 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.test.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-043 +// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-048 import React from 'react'; import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; @@ -602,3 +602,89 @@ describe('CollectionPlaceDetail: editable address', () => { expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('Somewhere'); }); }); + +// ── Cover controls sit beside the close button ─────────────────────────────── + +describe('CollectionPlaceDetail: cover controls', () => { + it('FE-COMP-COLDETAIL-044: the camera sits in the top right row, just left of the close button', async () => { + renderDetail({ canEdit: true, onUploadImage: vi.fn() }); + const camera = await screen.findByRole('button', { name: 'Upload image' }); + const close = screen.getByRole('button', { name: 'Close' }); + + const row = close.parentElement as HTMLElement; + expect(camera.parentElement).toBe(row); + expect(camera.nextElementSibling).toBe(close); + // Same class as the close button, so the same round look, hover and phone size. + expect(camera).toHaveClass('col-detail-close'); + // The category chip keeps the top left corner to itself. + const chip = screen.getByText('Food').closest('.col-detail-cover-cat') as HTMLElement; + expect(row.contains(chip)).toBe(false); + }); + + it('FE-COMP-COLDETAIL-045: with a custom cover the row reads remove, change, close and change still opens the picker', async () => { + const user = userEvent.setup(); + renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: { ...place, image_url: '/uploads/places/mock.jpg' } }); + const change = await screen.findByRole('button', { name: 'Change image' }); + const remove = screen.getByRole('button', { name: 'Remove image' }); + const close = screen.getByRole('button', { name: 'Close' }); + + expect(Array.from((close.parentElement as HTMLElement).querySelectorAll('button'))).toEqual([remove, change, close]); + expect(remove).toHaveClass('col-detail-close'); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const clicked = vi.fn(); + input.addEventListener('click', clicked); + await user.click(change); + expect(clicked).toHaveBeenCalledTimes(1); + }); + + it('FE-COMP-COLDETAIL-046: while an upload runs the camera spins, ignores clicks and the remove control steps aside', async () => { + const user = userEvent.setup(); + const onUploadImage = vi.fn(() => new Promise(() => {})); + renderDetail({ canEdit: true, onUploadImage, place: { ...place, image_url: '/uploads/places/mock.jpg' } }); + await screen.findByRole('button', { name: 'Change image' }); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } }); + + const change = screen.getByRole('button', { name: 'Change image' }); + await waitFor(() => expect(change.querySelector('.animate-spin')).not.toBeNull()); + expect(screen.queryByRole('button', { name: 'Remove image' })).not.toBeInTheDocument(); + expect(change.nextElementSibling).toBe(screen.getByRole('button', { name: 'Close' })); + + const clicked = vi.fn(); + input.addEventListener('click', clicked); + await user.click(change); + expect(clicked).not.toHaveBeenCalled(); + }); + + it('FE-COMP-COLDETAIL-047: a long category shares the top bar with the controls and ellipsizes instead of running under them', async () => { + const long = 'Restaurants, cafes and late night bars'; + renderDetail({ + canEdit: true, + onUploadImage: vi.fn(), + place: { ...place, image_url: '/uploads/places/mock.jpg', category: { id: 1, name: long, color: '#f00', icon: null } }, + }); + const controls = (await screen.findByRole('button', { name: 'Close' })).parentElement as HTMLElement; + const text = screen.getByText(long); + const chip = text.closest('.col-detail-cover-cat') as HTMLElement; + const bar = controls.parentElement as HTMLElement; + + expect(controls.querySelectorAll('button')).toHaveLength(3); + // Chip first, controls last in one flex bar: the chip only gets what the buttons leave. + expect(Array.from(bar.children)).toEqual([chip, controls]); + expect(bar).toHaveClass('absolute', 'flex', 'left-[14px]', 'right-[12px]', 'top-[12px]'); + expect(controls).toHaveClass('ms-auto', 'flex-none'); + expect(chip).toHaveClass('min-w-0'); + expect(text).toHaveClass('truncate'); + }); + + it('FE-COMP-COLDETAIL-048: without a category the controls still hold the end of the bar', async () => { + renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: { ...place, category: undefined } }); + const controls = (await screen.findByRole('button', { name: 'Close' })).parentElement as HTMLElement; + + expect(document.querySelector('.col-detail-cover-cat')).toBeNull(); + expect(Array.from((controls.parentElement as HTMLElement).children)).toEqual([controls]); + expect(controls).toHaveClass('ms-auto'); + }); +}); diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index ec87ebe44..7574c95ef 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -180,39 +180,45 @@ export default function CollectionPlaceDetail({
{banner && }
- {place.category?.name && ( - - {place.category.name} - - )} - - {canEdit && onUploadImage && ( -
- - - - {place.image_url && !imgBusy && ( - - - + {/* Chip and cover controls share the top bar. The controls keep the end and + never shrink, so a long category name ellipsizes instead of sliding under + them, however many buttons there are and whatever size phones give them. */} +
+ {place.category?.name && ( + + + {place.category.name} + + )} + {/* The cover controls wear the close button's class, so they get its look, + hover and phone size. */} +
+ {canEdit && onUploadImage && ( + <> + + {place.image_url && !imgBusy && ( + + + + )} + + + + )} - +
- )} +
{editing ? setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} /> diff --git a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx index a0421a1ba..92faf9be3 100644 --- a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx +++ b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx @@ -23,9 +23,10 @@ function linkHost(url: string): string { // Hero chrome sits on a photo/gradient — fixed white/black scrims in both themes. const HERO_CAT_CHIP = - 'relative inline-flex items-center gap-1 rounded-full bg-[rgba(255,255,255,.9)] px-[10px] py-[3px] font-geist text-[0.59375rem] font-extrabold text-[#101013]' // theme-lint-disable -const HERO_CLOSE = - 'absolute right-[14px] top-[14px] z-[1] flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(0,0,0,.28)] text-white' // theme-lint-disable + 'inline-flex min-w-0 items-center gap-1 rounded-full bg-[rgba(255,255,255,.9)] px-[10px] py-[3px] font-geist text-[0.59375rem] font-extrabold text-[#101013]' // theme-lint-disable +// Close and the cover controls beside it. +const HERO_BTN = + 'flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(0,0,0,.28)] text-white' // theme-lint-disable interface MCollPlaceSheetProps { place: CollectionPlace | null @@ -158,7 +159,7 @@ export default function MCollPlaceSheet({ <> {/* Hero: auto cover (photo when available, the design gradient otherwise) */}
{cover && ( @@ -167,43 +168,42 @@ export default function MCollPlaceSheet({
)} - {held.category?.name && ( - - {held.category.name} - - )} - - {canEdit && onUploadImage && ( -
- - {held.image_url && !imgBusy && ( - + {/* Chip and cover controls share the top row, in flow: the name always + starts below the buttons, chip or not, and a long category name + ellipsizes against them instead of running underneath. */} +
+ {held.category?.name && ( + + + {held.category.name} + + )} + {/* -me-1 keeps the buttons 14px off the edge, where close has always sat. */} +
+ {canEdit && onUploadImage && ( + <> + + {held.image_url && !imgBusy && ( + + )} + + )} - +
- )} -
{held.name}
+
+
{held.name}
diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index 23d181c13..841a8b13d 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -431,9 +431,10 @@ .trek-dash .col-detail-cover { position: relative; height: 116px; flex-shrink: 0; background-size: cover; background-position: center; } .trek-dash .col-detail-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } .trek-dash .col-detail-cover-scrim { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,.12) 0%, rgba(0,0,0,.55) 100%); } -.trek-dash .col-detail-cover-cat { position: absolute; top: 12px; left: 14px; z-index: 2; display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px 4px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; color: #fff; background: color-mix(in oklch, var(--cat, #6366f1) 82%, black); box-shadow: 0 2px 8px rgba(0,0,0,.3); } +/* chip and close sit in the cover's top bar (CollectionPlaceDetail), which places them */ +.trek-dash .col-detail-cover-cat { display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px 4px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; color: #fff; background: color-mix(in oklch, var(--cat, #6366f1) 82%, black); box-shadow: 0 2px 8px rgba(0,0,0,.3); } .trek-dash .col-detail-close { - position: absolute; top: 12px; right: 12px; z-index: 2; width: 32px; height: 32px; border-radius: 50%; + width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: rgba(0,0,0,.4); color: #fff; border: 1px solid rgba(255,255,255,.25); backdrop-filter: blur(8px); transition: background .12s; } diff --git a/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx b/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx index 18431cb83..16c847c47 100644 --- a/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx +++ b/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx @@ -8,7 +8,7 @@ import type { Category } from '../../../../src/types' import { resetAllStores } from '../../../helpers/store' import { useTranslation } from '../../../../src/i18n' -// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-037 +// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-041 // react-markdown ships ESM-only chunks jsdom chokes on; the sheet only needs // the raw description text to reach the renderer. @@ -418,4 +418,64 @@ describe('MCollPlaceSheet', () => { fireEvent.click(screen.getByRole('button', { name: 'Edit' })) expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('2-6-15 Minami-Aoyama') }) + + // ── Cover controls beside the close button ───────────────────────────────── + + it('FE-MOB-CPLSH-038: the camera sits in the top right row, just left of close, and looks like it', () => { + setup({ onUploadImage: vi.fn() }) + const camera = screen.getByRole('button', { name: 'Upload image' }) + const close = screen.getByRole('button', { name: 'Close' }) + + const row = close.parentElement as HTMLElement + expect(camera.parentElement).toBe(row) + expect(camera.nextElementSibling).toBe(close) + expect(camera.className).toBe(close.className) + // The category chip has the top left corner to itself again. + expect(row.contains(screen.getByText('Restaurant'))).toBe(false) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const clicked = vi.fn() + input.addEventListener('click', clicked) + fireEvent.click(camera) + expect(clicked).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-CPLSH-039: with a custom cover the row reads remove, change, close', () => { + setup({ onUploadImage: vi.fn(), place: place({ image_url: '/uploads/places/n.jpg' }) }) + const remove = screen.getByRole('button', { name: 'Remove image' }) + const change = screen.getByRole('button', { name: 'Change image' }) + const close = screen.getByRole('button', { name: 'Close' }) + + expect(Array.from((close.parentElement as HTMLElement).querySelectorAll('button'))).toEqual([remove, change, close]) + expect(remove.className).toBe(close.className) + }) + + it('FE-MOB-CPLSH-040: without a category chip the name still starts below the full control row', () => { + setup({ onUploadImage: vi.fn(), place: place({ category: undefined, category_id: null, image_url: '/uploads/places/n.jpg' }) }) + const controls = screen.getByRole('button', { name: 'Close' }).parentElement as HTMLElement + const top = controls.parentElement as HTMLElement + + expect(controls.querySelectorAll('button')).toHaveLength(3) + // The row is in flow rather than pinned over the hero, and the name follows it. + expect(top).not.toHaveClass('absolute') + expect(controls).not.toHaveClass('absolute') + expect(Array.from(top.children)).toEqual([controls]) + expect(top.nextElementSibling).toBe(screen.getByText('Narisawa')) + }) + + it('FE-MOB-CPLSH-041: a long category ellipsizes against the controls instead of running under them', () => { + const long = 'Restaurants, cafes and late night bars' + setup({ + onUploadImage: vi.fn(), + place: place({ category: { id: 3, name: long, color: '#E8843D', icon: 'Utensils' }, image_url: '/uploads/places/n.jpg' }), + }) + const controls = screen.getByRole('button', { name: 'Close' }).parentElement as HTMLElement + const text = screen.getByText(long) + const chip = text.parentElement as HTMLElement + + expect(Array.from((controls.parentElement as HTMLElement).children)).toEqual([chip, controls]) + expect(controls).toHaveClass('ms-auto', 'flex-none') + expect(chip).toHaveClass('min-w-0') + expect(text).toHaveClass('truncate') + }) })