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.
This commit is contained in:
Maurice
2026-09-19 19:24:37 +02:00
parent 66061a05df
commit 40f8912937
5 changed files with 227 additions and 74 deletions
@@ -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<void>(() => {}));
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');
});
});
@@ -180,39 +180,45 @@ export default function CollectionPlaceDetail({
<div className="col-detail-cover" style={banner ? undefined : { backgroundImage: entityGradient(place.id) }}>
{banner && <img src={banner} alt="" />}
<div className="col-detail-cover-scrim" />
{place.category?.name && (
<span className="col-detail-cover-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={12} /> {place.category.name}
</span>
)}
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
{canEdit && onUploadImage && (
<div style={{ position: 'absolute', top: 10, left: 10, display: 'flex', gap: 6, zIndex: 2 }}>
<Tooltip label={place.image_url ? t('places.changeImage') : t('places.uploadImage')} placement="bottom">
<button
type="button"
onClick={() => { if (!imgBusy) coverInputRef.current?.click() }}
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: imgBusy ? 'default' : 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
>
{imgBusy ? <Loader2 size={15} className="animate-spin" /> : <Camera size={15} />}
</button>
</Tooltip>
{place.image_url && !imgBusy && (
<Tooltip label={t('places.removeImage')} placement="bottom">
<button
type="button"
onClick={handleImageRemove}
aria-label={t('places.removeImage')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
>
<Trash2 size={15} />
</button>
</Tooltip>
{/* 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. */}
<div className="absolute left-[14px] right-[12px] top-[12px] z-[2] flex items-center gap-[8px]">
{place.category?.name && (
<span className="col-detail-cover-cat min-w-0" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={12} className="flex-none" />
<span className="truncate">{place.category.name}</span>
</span>
)}
{/* The cover controls wear the close button's class, so they get its look,
hover and phone size. */}
<div className="ms-auto flex flex-none gap-[6px]">
{canEdit && onUploadImage && (
<>
<input ref={coverInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleCoverPick} />
{place.image_url && !imgBusy && (
<Tooltip label={t('places.removeImage')} placement="bottom">
<button type="button" className="col-detail-close" onClick={handleImageRemove} aria-label={t('places.removeImage')}>
<Trash2 size={16} />
</button>
</Tooltip>
)}
<Tooltip label={place.image_url ? t('places.changeImage') : t('places.uploadImage')} placement="bottom">
<button
type="button"
className="col-detail-close"
style={imgBusy ? { cursor: 'default' } : undefined}
onClick={() => { if (!imgBusy) coverInputRef.current?.click() }}
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
>
{imgBusy ? <Loader2 size={16} className="animate-spin" /> : <Camera size={16} />}
</button>
</Tooltip>
</>
)}
<input ref={coverInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleCoverPick} />
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
</div>
)}
</div>
<div className="col-detail-head">
{editing
? <input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} />
@@ -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) */}
<div
className="relative flex-none px-[18px] py-4"
className="relative flex-none px-[18px] pb-4 pt-[14px]"
style={cover ? undefined : { background: 'linear-gradient(120deg,#2FA9A0,#3B8C7E)' }}
>
{cover && (
@@ -167,43 +168,42 @@ export default function MCollPlaceSheet({
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,.18),rgba(0,0,0,.44))]" />
</>
)}
{held.category?.name && (
<span className={HERO_CAT_CHIP}>
<MapPin size={9} strokeWidth={2.6} /> {held.category.name}
</span>
)}
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className={HERO_CLOSE}
>
<X size={15} strokeWidth={2.2} />
</button>
{canEdit && onUploadImage && (
<div className="absolute left-[14px] top-[14px] z-[1] flex gap-[6px]">
<button
type="button"
onClick={() => { if (!imgBusy) imageInputRef.current?.click() }}
aria-label={held.image_url ? t('places.changeImage') : t('places.uploadImage')}
className="flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(0,0,0,.4)] text-white" // theme-lint-disable
>
{imgBusy ? <Loader2 size={14} className="animate-spin" /> : <Camera size={14} />}
</button>
{held.image_url && !imgBusy && (
<button
type="button"
onClick={handleImageRemove}
aria-label={t('places.removeImage')}
className="flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(0,0,0,.4)] text-white" // theme-lint-disable
>
<Trash2 size={14} />
</button>
{/* 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. */}
<div className="relative flex items-center gap-2">
{held.category?.name && (
<span className={HERO_CAT_CHIP}>
<MapPin size={9} strokeWidth={2.6} className="flex-none" />
<span className="truncate">{held.category.name}</span>
</span>
)}
{/* -me-1 keeps the buttons 14px off the edge, where close has always sat. */}
<div className="-me-1 ms-auto flex flex-none gap-[6px]">
{canEdit && onUploadImage && (
<>
<input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" className="hidden" onChange={handleImagePick} />
{held.image_url && !imgBusy && (
<button type="button" onClick={handleImageRemove} aria-label={t('places.removeImage')} className={HERO_BTN}>
<Trash2 size={15} strokeWidth={2.2} />
</button>
)}
<button
type="button"
onClick={() => { if (!imgBusy) imageInputRef.current?.click() }}
aria-label={held.image_url ? t('places.changeImage') : t('places.uploadImage')}
className={HERO_BTN}
>
{imgBusy ? <Loader2 size={15} strokeWidth={2.2} className="animate-spin" /> : <Camera size={15} strokeWidth={2.2} />}
</button>
</>
)}
<input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" className="hidden" onChange={handleImagePick} />
<button type="button" onClick={onClose} aria-label={t('common.close')} className={HERO_BTN}>
<X size={15} strokeWidth={2.2} />
</button>
</div>
)}
<div className="relative mt-[10px] text-[1.3125rem] font-extrabold text-white">{held.name}</div>
</div>
<div className="relative mt-1 text-[1.3125rem] font-extrabold text-white">{held.name}</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
+3 -2
View File
@@ -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;
}
@@ -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')
})
})