From d2c7d8325613c49174aa9289aa956cdaec29236e Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 5 Nov 2026 12:19:45 +0100 Subject: [PATCH] fix(collections): keep a place's name readable next to long labels A place row was a three-column grid whose last column held the labels, the category and the status, none of which could shrink. With two or three long labels the name got what was left, often one letter or nothing, and the status pill ran out of the card. The row now wraps: the name keeps at least 11rem, and when the labels, category and status do not fit beside it they go to a line of their own under it and wrap there. A label or category too long for its chip ends in an ellipsis instead of being cut mid-letter. Kept tours use the same row and get the same layout. Closes #2981 --- client/e2e/collection-row-names-2981.spec.ts | 92 +++++++++++++++++++ .../components/Collections/CollectionList.tsx | 4 +- client/src/styles/collections.css | 14 ++- 3 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 client/e2e/collection-row-names-2981.spec.ts diff --git a/client/e2e/collection-row-names-2981.spec.ts b/client/e2e/collection-row-names-2981.spec.ts new file mode 100644 index 000000000..4c5857193 --- /dev/null +++ b/client/e2e/collection-row-names-2981.spec.ts @@ -0,0 +1,92 @@ +import { expect, test, type Locator } from '@playwright/test'; + +// #2981: labels, category and status took the whole row, so a place with two or +// three long labels showed one letter of its name or none, and the status pill +// ran out of the card. Measured in a real browser because the layout is the bug. +const PLACES = [ + { name: 'Oceanario de Lisboa', labels: ['Core shortlist', 'Heritage specialist', 'Pair with another stop'] }, + { name: 'Cove of Ursa', labels: ['Heritage specialist', 'Pair with another stop'] }, + { name: 'A Vida Portuguesa', labels: ['Flagship', 'Market', 'Core shortlist', 'Pair with another stop'] }, + { name: 'Torre de Belem', labels: [] as string[] }, +]; + +async function expectInside(row: Locator) { + const escapes = await row.evaluate((el) => { + const box = el.getBoundingClientRect(); + return Array.from(el.querySelectorAll('*')).filter((child) => { + const r = child.getBoundingClientRect(); + return r.width > 0 && (r.left < box.left - 0.5 || r.right > box.right + 0.5); + }).length; + }); + expect(escapes).toBe(0); +} + +for (const viewport of [ + { width: 1400, height: 900 }, + { width: 1024, height: 800 }, +]) { + test(`place names stay readable next to long labels at ${viewport.width}px`, async ({ page }) => { + await page.setViewportSize(viewport); + const addonsResponse = await page.request.get('/api/admin/addons'); + expect(addonsResponse.ok()).toBeTruthy(); + const { addons } = await addonsResponse.json(); + const original = addons.find((addon: { id: string }) => addon.id === 'collections'); + expect(original).toBeDefined(); + let collectionId: number | undefined; + + try { + expect((await page.request.put('/api/admin/addons/collections', { data: { enabled: true } })).ok()).toBeTruthy(); + const created = await page.request.post('/api/addons/collections', { data: { name: `Rows ${viewport.width}` } }); + expect(created.ok()).toBeTruthy(); + const body = await created.json(); + collectionId = (body.collection ?? body).id as number; + + const labelIds = new Map(); + for (const name of new Set(PLACES.flatMap((p) => p.labels))) { + const res = await page.request.post('/api/addons/collections/labels', { + data: { collection_id: collectionId, name, color: '#2563eb' }, + }); + expect(res.ok(), `label ${name}`).toBeTruthy(); + const label = await res.json(); + labelIds.set(name, (label.label ?? label).id); + } + for (const place of PLACES) { + const res = await page.request.post('/api/addons/collections/places', { + data: { + collection_id: collectionId, + name: place.name, + address: 'Rua Anchieta 11, 1200-023 Lisboa, Portugal', + lat: 38.71, + lng: -9.14, + }, + }); + expect(res.ok(), `place ${place.name}`).toBeTruthy(); + const saved = await res.json(); + if (!place.labels.length) continue; + const assign = await page.request.post('/api/addons/collections/labels/assign', { + data: { label_ids: place.labels.map((l) => labelIds.get(l)), place_ids: [(saved.place ?? saved).id] }, + }); + expect(assign.ok()).toBeTruthy(); + } + + await page.goto(`/collections/${collectionId}`); + const rows = page.locator('.col-lrow'); + await expect(rows).toHaveCount(PLACES.length); + for (const place of PLACES) { + const row = rows.filter({ hasText: place.name }); + const name = row.locator('.li .t'); + await expect(name).toHaveText(place.name); + // The whole name fits: it is not cut to an ellipsis. + expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); + await expectInside(row); + } + } finally { + if (collectionId !== undefined) { + expect((await page.request.delete(`/api/addons/collections/${collectionId}`)).ok()).toBeTruthy(); + } + expect( + (await page.request.put('/api/admin/addons/collections', { data: { enabled: !!original.enabled } })).ok() + ).toBeTruthy(); + } + }); +} diff --git a/client/src/components/Collections/CollectionList.tsx b/client/src/components/Collections/CollectionList.tsx index 1ba3f50b0..d7dfc3019 100644 --- a/client/src/components/Collections/CollectionList.tsx +++ b/client/src/components/Collections/CollectionList.tsx @@ -70,7 +70,7 @@ export default function CollectionList({
{placeLabels.slice(0, 2).map(l => ( - {l.name} + {l.name} ))} {placeLabels.length > 2 && l.name).join(', ')}>+{placeLabels.length - 2}} @@ -79,7 +79,7 @@ export default function CollectionList({ return ( <> - {place.category.name} + {place.category.name} diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index 9533458b7..391be0f35 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -254,8 +254,12 @@ /* ----------------- list view ----------------- */ .trek-dash .col-listview { display: flex; flex-direction: column; gap: 7px; } +/* A row is the place's tile, its name and address, and an end group of labels, category + and status. The name keeps at least 11rem; when the end group does not fit beside it, + the group goes to a line of its own and wraps there, so a place with long labels still + shows which place it is and its status stays inside the card (#2981). */ .trek-dash .col-lrow { - display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: 14px; align-items: center; + display: flex; flex-wrap: wrap; column-gap: 14px; row-gap: 8px; align-items: center; padding: 10px 14px; border-radius: var(--r-md); background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--sh-xs); cursor: pointer; transition: transform .12s, box-shadow .15s, border-color .15s; @@ -265,15 +269,19 @@ .trek-dash .col-lrow:hover { transform: translateX(3px); box-shadow: var(--sh-sm); } /* Inset ring so the highlight is never clipped by the column's overflow. */ .trek-dash .col-lrow.sel { border-color: var(--accent); box-shadow: inset 0 0 0 1.5px var(--accent); } -.trek-dash .col-lrow .li { min-width: 0; } +.trek-dash .col-lrow > :first-child { flex: none; } +/* The select box is narrower than a tile; the margin keeps the names lined up. */ +.trek-dash .col-lrow > .col-lcheck { margin-inline-end: 18px; } +.trek-dash .col-lrow .li { flex: 1 1 11rem; min-width: 0; } .trek-dash .col-lrow .li .t { font-size: 14px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .trek-dash .col-lrow .li .s { font-size: 12px; color: var(--ink-3); margin-top: 2px; display: flex; align-items: center; gap: 5px; overflow: hidden; } .trek-dash .col-lrow .li .s span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* The pin before the address must not be squeezed by a long address. */ .trek-dash .col-lrow .li .s svg { flex-shrink: 0; } -.trek-dash .col-lrow-end { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.trek-dash .col-lrow-end { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 6px 8px; flex: 0 1 auto; max-width: 100%; min-width: 0; margin-inline-start: auto; } .trek-dash .col-lrow-cat { display: inline-flex; align-items: center; gap: 5px; max-width: 130px; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--cat, var(--accent)); background: color-mix(in oklch, var(--cat, var(--accent)) 14%, transparent); } .trek-dash .col-lrow-cat svg { flex-shrink: 0; } +.trek-dash .col-lrow-txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .trek-dash .col-lrow-div { width: 1px; height: 18px; flex-shrink: 0; background: var(--line-2); } /* A kept tour (LIB-03) is a row of the same list: its grade tile stands where