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') + }) })