mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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]">
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user