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
This commit is contained in:
Maurice
2026-11-05 12:19:45 +01:00
parent 91630dd811
commit d2c7d83256
3 changed files with 105 additions and 5 deletions
@@ -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<string, number>();
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();
}
});
}
@@ -70,7 +70,7 @@ export default function CollectionList({
<div className="col-lrow-end">
{placeLabels.slice(0, 2).map(l => (
<span key={l.id} className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }} title={l.name}>
<span className="col-labelchip-dot" /> {l.name}
<span className="col-labelchip-dot" /> <span className="col-lrow-txt">{l.name}</span>
</span>
))}
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
@@ -79,7 +79,7 @@ export default function CollectionList({
return (
<>
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={11} /> {place.category.name}
<CatIcon size={11} /> <span className="col-lrow-txt">{place.category.name}</span>
</span>
<span className="col-lrow-div" aria-hidden />
</>
+11 -3
View File
@@ -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