Compare commits

...

8 Commits

Author SHA1 Message Date
Maurice 91f6430762 test(collections): read a row's +N without waiting for one 2026-11-05 14:18:04 +01:00
Maurice 6ee540d5e4 test(collections): hover the +N of a row that has a category beside its labels 2026-11-05 13:53:57 +01:00
Maurice 99cdcfcd09 test(collections): cover fitting a row's labels and folding the map's label strip 2026-11-05 13:31:25 +01:00
Maurice fd11944ed6 test(collections): count the labels a row shows, and give the three-viewport spec room to clean up 2026-11-05 13:29:27 +01:00
Maurice 82c6a3cd5f fix(collections): name a hidden label's class without a leading space prettier strips 2026-11-05 13:10:15 +01:00
Maurice fbafa270c4 fix(collections): fit a row's labels without setting state in an effect, and bring them back when the row grows
The fitting ran in a layout effect and measured chips the row had already
squeezed, so a row that got wider kept its "+N". Every label now stays in
the row, the ones without room kept out of sight, and the observer that
sees the row change size fits them from each chip's full width. The name
takes the width it needs on a wide list and the labels fill the rest.
2026-11-05 13:01:49 +01:00
Maurice 1c54d7d6c1 fix(collections): give place rows two fixed lines and fit labels to the room
The first fix let a row wrap freely, which on a list beside the map put
the status on a line of its own and broke every row differently. A row is
now laid out the same way each time: on a list narrower than 760px the
name, address and status take the first line and the category and labels
one line below; a wider list keeps them on one line. The row shows as
many labels as it has room for, category first, and a "+N" chip names
the rest in its tooltip, so a wide screen shows every label. Chips that
cut a name short say it in full in their tooltip.

The label strip over the map hid the labels that did not fit behind a
scroll with no scrollbar. It now folds into a "Labels" button that opens
them as a list when the bar has no room for all of them, and unfolds
again when the room is back. The filter row stays on one line on a
desktop, a dropdown at its default keeping only its icon when narrow, and
the select bar's actions keep their icons with the words in a tooltip
when the bar is too narrow for them.
2026-11-05 12:46:16 +01:00
Maurice d2c7d83256 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
2026-11-05 12:33:05 +01:00
9 changed files with 678 additions and 69 deletions
@@ -0,0 +1,104 @@
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) => {
// The strip's hidden measuring copy is meant to run past the bar.
if (child.closest('[aria-hidden="true"]')) return false;
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: 1920, height: 1000 },
{ width: 1400, height: 900 },
{ width: 1024, height: 800 },
]) {
test(`place names stay readable next to long labels at ${viewport.width}px`, async ({ page }) => {
test.setTimeout(90_000);
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);
// Every label is either on the row or counted by its "+N" chip.
const shown = await row.locator('.col-lrow-label:not(.is-over)').count();
// Read without waiting: a row whose labels all fit has no "+N".
const [more] = await row.locator('.col-lrow-more').allTextContents();
expect(shown + (more ? Number(more.replace('+', '')) : 0)).toBe(place.labels.length);
}
// The map's label strip stays inside the map: whole, or folded into its button.
const topbar = page.locator('.col-map-topbar');
if (await topbar.count()) await expectInside(topbar);
} 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();
}
});
}
@@ -35,7 +35,7 @@ function Dropdown({ current, options, onSelect, lead }: {
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="col-filter" ref={ref}>
<button type="button" className={`col-filter-btn${open ? ' open' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}>
<button type="button" className={`col-filter-btn${open ? ' open' : ''}${cur.key === options[0]?.key ? ' is-default' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open} title={cur.label}>
{cur.icon ?? lead}
<span className="col-filter-lbl">{cur.label}</span>
<ChevronDown size={14} className="col-filter-chev" />
@@ -0,0 +1,121 @@
// FE-COMP-COLLABELS-001 to FE-COMP-COLLABELS-006
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import type { TranslationFn } from '../../types';
import CollectionLabelFilter, { type LabelOption } from './CollectionLabelFilter';
const t = ((key: string) => key) as unknown as TranslationFn;
const LABELS = [
{ id: 1, name: 'Core shortlist', color: '#2563eb', count: 2 },
{ id: 2, name: 'Heritage', color: null, count: 0 },
] as unknown as LabelOption[];
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(() => vi.restoreAllMocks());
/** The labels need `need` px against a slot of `room` px beside the map's controls. */
function room(need: number, room: number) {
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) {
return this.classList.contains('col-labelfilter-measure') ? need : 0;
});
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function (this: Element) {
return this.classList.contains('col-labelfilter-slot') ? room : 0;
});
}
function setup(props: Partial<Parameters<typeof CollectionLabelFilter>[0]> = {}) {
const onLabelFilter = vi.fn();
const onManageLabels = vi.fn();
const view = render(
<CollectionLabelFilter
labelOptions={LABELS}
labelFilter={[]}
onLabelFilter={onLabelFilter}
canManageLabels
onManageLabels={onManageLabels}
t={t}
{...props}
/>
);
return { ...view, onLabelFilter, onManageLabels };
}
describe('CollectionLabelFilter', () => {
it('FE-COMP-COLLABELS-001: the filter row toggles a label on and off', () => {
const on = setup();
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
expect(on.onLabelFilter).toHaveBeenCalledWith([1]);
on.unmount();
const off = setup({ labelFilter: [1, 2] });
expect(screen.getByRole('button', { name: /Core shortlist/ })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
expect(off.onLabelFilter).toHaveBeenCalledWith([2]);
});
it('FE-COMP-COLLABELS-002: offers to add a label when there is none, and to manage them otherwise', () => {
const empty = setup({ labelOptions: [] });
fireEvent.click(screen.getByRole('button', { name: 'collections.labels.add' }));
expect(empty.onManageLabels).toHaveBeenCalled();
empty.unmount();
setup();
expect(screen.getByRole('button', { name: 'collections.labels.manage' })).toBeInTheDocument();
});
it('FE-COMP-COLLABELS-003: on the map the strip shows its labels while they fit', () => {
room(300, 500);
setup({ variant: 'map' });
expect(screen.getByRole('button', { name: /Heritage/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /collections.labels.title/ })).toBeNull();
});
it('FE-COMP-COLLABELS-004: without room it folds into one button that lists the labels', () => {
room(600, 200);
const { onLabelFilter } = setup({ variant: 'map', labelFilter: [2] });
const fold = screen.getByRole('button', { name: /collections.labels.title/ });
expect(fold).toHaveTextContent('1');
expect(fold).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(fold);
const list = screen.getByRole('group', { name: 'collections.labels.title' });
expect(list).toHaveTextContent('Core shortlist');
expect(screen.getByRole('button', { name: /Heritage/ })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
expect(onLabelFilter).toHaveBeenCalledWith([2, 1]);
});
it('FE-COMP-COLLABELS-005: the folded list manages labels and closes on Escape or a click beside it', () => {
room(600, 200);
const { onManageLabels } = setup({ variant: 'map' });
const fold = screen.getByRole('button', { name: /collections.labels.title/ });
fireEvent.click(fold);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
fireEvent.click(fold);
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
fireEvent.click(fold);
fireEvent.click(screen.getByRole('button', { name: /collections.labels.manage/ }));
expect(onManageLabels).toHaveBeenCalled();
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
});
it('FE-COMP-COLLABELS-006: a click inside the folded list keeps it open', () => {
room(600, 200);
setup({ variant: 'map' });
fireEvent.click(screen.getByRole('button', { name: /collections.labels.title/ }));
fireEvent.mouseDown(screen.getByRole('group', { name: 'collections.labels.title' }));
expect(screen.getByRole('group', { name: 'collections.labels.title' })).toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import React from 'react'
import { Plus, Settings2, Tags } from 'lucide-react'
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Check, ChevronDown, Plus, Settings2, Tags } from 'lucide-react'
import type { LabelOption } from '../../pages/collections/collectionsModel'
import type { TranslationFn } from '../../types'
@@ -19,54 +19,154 @@ interface CollectionLabelFilterProps {
t: TranslationFn
}
interface ChipsProps {
labelOptions: LabelOption[]
labelFilter: number[]
onToggle?: (id: number) => void
}
/** The label chips, as buttons, or as plain spans for the copy the map strip measures. */
function LabelChips({ labelOptions, labelFilter, onToggle }: ChipsProps): React.ReactElement {
return (
<>
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
const body = (
<>
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
</>
)
const style = { ['--label' as string]: l.color ?? 'var(--accent)' }
if (!onToggle) return <span key={l.id} className={`col-labelchip${on ? ' on' : ''}`} style={style}>{body}</span>
return (
<button key={l.id} type="button" className={`col-labelchip${on ? ' on' : ''}`} style={style} onClick={() => onToggle(l.id)} aria-pressed={on}>
{body}
</button>
)
})}
</>
)
}
/**
* Whether the map strip has to fold into its button: true once its chips need more room
* than the slot beside the map's other controls. Measured on a hidden copy, so the strip
* unfolds again as soon as the room comes back.
*/
function useFolds(enabled: boolean) {
const slotRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const [folded, setFolded] = useState(false)
useLayoutEffect(() => {
const slot = slotRef.current
const measure = measureRef.current
if (!enabled || !slot || !measure) return
const check = () => setFolded(measure.scrollWidth > slot.clientWidth + 1)
check()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(check)
observer.observe(slot)
observer.observe(measure)
return () => observer.disconnect()
}, [enabled])
return { slotRef, measureRef, folded }
}
/** Open state of the folded strip's list: closed by a click beside it or Escape, like the filter row's dropdowns. */
function useFoldList(ref: React.RefObject<HTMLElement | null>) {
const [open, setOpen] = useState(false)
useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey) }
}, [open, ref])
return { open, setOpen }
}
/**
* The label filter, in one place because it has two homes.
*
* Labels are an axis of their own next to status, category and rating, so they
* are grouped into a tray instead of joining the row as more loose buttons. On
* a desktop it rides in the map's top bar, where there is room to spare; the
* filter row keeps it whenever no map is on screen, so it never becomes
* unreachable.
* a desktop it rides in the map's top bar; when the bar has no room for every
* label it folds into one button that opens them as a list, instead of hiding
* the last ones behind a scroll nobody sees. The filter row keeps it whenever
* no map is on screen, so it never becomes unreachable.
*/
export default function CollectionLabelFilter({
labelOptions, labelFilter, onLabelFilter, canManageLabels = false, onManageLabels,
variant = 'bar', t,
}: CollectionLabelFilterProps): React.ReactElement {
return (
<div
className={`col-labelfilter${variant === 'map' ? ' on-map' : ''}`}
role="group"
aria-label={t('collections.labels.manage')}
>
const onMap = variant === 'map'
const { slotRef, measureRef, folded } = useFolds(onMap)
const fold = useFoldList(slotRef)
const toggle = (id: number) => onLabelFilter(labelFilter.includes(id) ? labelFilter.filter(x => x !== id) : [...labelFilter, id])
const manageLabel = labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')
const manage = canManageLabels && onManageLabels && (
<button type="button" className="col-filter-addlabel" onClick={onManageLabels} title={manageLabel} aria-label={manageLabel}>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
</button>
)
const tray = (
<div className={`col-labelfilter${onMap ? ' on-map' : ''}`} role="group" aria-label={t('collections.labels.manage')}>
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<LabelChips labelOptions={labelOptions} labelFilter={labelFilter} onToggle={toggle} />
{manage}
</div>
)
if (!onMap) return tray
const active = labelFilter.filter(id => labelOptions.some(l => l.id === id)).length
return (
<div className="col-labelfilter-slot" ref={slotRef}>
{!folded ? tray : (
<>
<button
key={l.id}
type="button"
className={`col-labelchip${on ? ' on' : ''}`}
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
aria-pressed={on}
onClick={() => fold.setOpen(o => !o)}
aria-haspopup="true"
aria-expanded={fold.open}
className={`col-labelfilter on-map col-labelfilter-fold${fold.open ? ' open' : ''}`}
>
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
<span className="col-filter-lbl">{t('collections.labels.title')}</span>
{active > 0 && <span className="col-labelfilter-active">{active}</span>}
<ChevronDown size={14} className="col-filter-chev" aria-hidden="true" />
</button>
)
})}
{canManageLabels && onManageLabels && (
<button
type="button"
className="col-filter-addlabel"
onClick={onManageLabels}
title={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
aria-label={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
</button>
{fold.open && (
<div className="col-filter-pop col-labelfilter-pop" role="group" aria-label={t('collections.labels.title')}>
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<button key={l.id} type="button" aria-pressed={on} className={`col-filter-opt${on ? ' on' : ''}`} onClick={() => toggle(l.id)}>
<span className="col-filter-dot" style={{ background: l.color ?? 'var(--accent)' }} />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
{on && <Check size={13} className="col-filter-check" />}
</button>
)
})}
{canManageLabels && onManageLabels && (
<button type="button" className="col-filter-opt col-labelfilter-manage" onClick={() => { fold.setOpen(false); onManageLabels() }}>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
<span className="col-filter-lbl">{manageLabel}</span>
</button>
)}
</div>
)}
</>
)}
<div className="col-labelfilter on-map col-labelfilter-measure" ref={measureRef} aria-hidden="true">
<Tags size={13} className="col-labelfilter-lead" />
<LabelChips labelOptions={labelOptions} labelFilter={labelFilter} />
{canManageLabels && onManageLabels && <span className="col-filter-addlabel"><Settings2 size={13} /></span>}
</div>
</div>
)
}
@@ -3,8 +3,8 @@ import { Check, MapPin } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
import type { TranslationFn } from '../../types'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import StatusBadge from './StatusBadge'
import CollectionPlaceTags from './CollectionPlaceTags'
interface CollectionListProps {
places: CollectionPlace[]
@@ -51,7 +51,7 @@ export default function CollectionList({
tabIndex={0}
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (selectMode) onToggleSelect(place.id); else onOpenPlace(place.id) } }}
className={`col-lrow${active || selected ? ' sel' : ''}`}
className={`col-lrow col-lrow-place${active || selected ? ' sel' : ''}`}
>
{selectMode ? (
<span className={`col-lcheck${selected ? ' on' : ''}`}>{selected && <Check size={14} strokeWidth={3} />}</span>
@@ -67,24 +67,8 @@ export default function CollectionList({
</div>
)}
</div>
<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>
))}
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
{place.category?.name && (() => {
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
return (
<>
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={11} /> {place.category.name}
</span>
<span className="col-lrow-div" aria-hidden />
</>
)
})()}
<CollectionPlaceTags place={place} labels={placeLabels} />
<div className="col-lrow-status">
<StatusBadge status={place.status} onChange={selectMode || !onStatusChange ? undefined : next => onStatusChange(place.id, next)} t={t} />
</div>
</div>
@@ -0,0 +1,88 @@
// FE-COMP-COLTAGS-001 to FE-COMP-COLTAGS-005
import type { CollectionLabel, CollectionPlace } from '@trek/shared';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import CollectionPlaceTags from './CollectionPlaceTags';
// jsdom lays nothing out: the observer reports each element once it is observed, and
// the widths below stand in for the browser's.
beforeAll(() => {
globalThis.ResizeObserver = class {
constructor(private readonly cb: ResizeObserverCallback) {}
observe(target: Element) {
this.cb([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(() => vi.restoreAllMocks());
/** Row and chip widths in px: the row holds the category, one label and the "+N" chip. */
function layout({ row = 250, cutName }: { row?: number; cutName?: string } = {}) {
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
if (this.classList.contains('col-lrow-cat')) return 50;
if (this.classList.contains('col-lrow-label')) return 100;
return 0;
});
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function (this: Element) {
if (this.classList.contains('col-lrow-tags')) return row;
if (this.classList.contains('col-lrow-txt')) return 60;
return 0;
});
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) {
if (this.classList.contains('col-lrow-txt')) return this.textContent === cutName ? 90 : 60;
return 0;
});
}
const label = (id: number, name: string): CollectionLabel =>
({ id, collection_id: 1, name, color: '#2563eb' }) as unknown as CollectionLabel;
const place = (category: { name: string; color?: string; icon?: string } | null = { name: 'Attraction' }) =>
({ id: 7, name: 'Torre', category }) as unknown as CollectionPlace;
const LABELS = [label(1, 'Core shortlist'), label(2, 'Heritage'), label(3, 'Pair with another stop')];
describe('CollectionPlaceTags', () => {
it('FE-COMP-COLTAGS-001: draws nothing for a place without labels or a category', () => {
const { container } = render(<CollectionPlaceTags place={place(null)} labels={[]} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-COMP-COLTAGS-002: shows the category first, then the labels that fit, then a "+N" for the rest', () => {
layout();
const { container } = render(<CollectionPlaceTags place={place()} labels={LABELS} />);
const chips = Array.from(container.querySelectorAll('.col-lrow-tags > span')).map((el) => el.className);
expect(chips[0]).toBe('col-lrow-cat');
expect(container.querySelectorAll('.col-lrow-label:not(.is-over)')).toHaveLength(1);
expect(container.querySelectorAll('.col-lrow-label.is-over')).toHaveLength(2);
expect(container.querySelector('.col-lrow-label.is-over')).toHaveAttribute('aria-hidden', 'true');
expect(screen.getByText('+2')).toBeInTheDocument();
});
it('FE-COMP-COLTAGS-003: a row wide enough shows every label and no "+N"', () => {
layout({ row: 800 });
const { container } = render(<CollectionPlaceTags place={place()} labels={LABELS} />);
expect(container.querySelectorAll('.col-lrow-label:not(.is-over)')).toHaveLength(3);
expect(container.querySelector('.col-lrow-more')).toBeNull();
});
it('FE-COMP-COLTAGS-004: the "+N" names the hidden labels in its tooltip', async () => {
layout();
render(<CollectionPlaceTags place={place()} labels={LABELS} />);
fireEvent.mouseEnter(screen.getByText('+2'));
expect(await screen.findByRole('tooltip')).toHaveTextContent('Heritage, Pair with another stop');
});
it('FE-COMP-COLTAGS-005: a chip says its name in a tooltip only while it cuts the name short', async () => {
layout({ row: 800, cutName: 'Heritage' });
render(<CollectionPlaceTags place={place()} labels={LABELS} />);
fireEvent.mouseEnter(screen.getByText('Core shortlist').closest('.col-lrow-label') as HTMLElement);
await new Promise((r) => setTimeout(r, 350));
expect(screen.queryByRole('tooltip')).toBeNull();
fireEvent.mouseLeave(screen.getByText('Core shortlist').closest('.col-lrow-label') as HTMLElement);
fireEvent.mouseEnter(screen.getByText('Heritage').closest('.col-lrow-label') as HTMLElement);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Heritage');
});
});
@@ -0,0 +1,145 @@
import type { CollectionLabel, CollectionPlace } from '@trek/shared';
import React, { createElement, useEffect, useRef, useState } from 'react';
import { getCategoryIcon } from '../shared/categoryIcons';
import { Tooltip } from '../shared/Tooltip';
/**
* One observer for every row and chip of a list, so a list of hundreds of places does
* not hold one per element. An element starts with one notification once observed.
*/
const sizeWatch = new Map<Element, () => void>();
let sizeObserver: ResizeObserver | null = null;
function watchSize(el: Element, onResize: () => void): () => void {
if (typeof ResizeObserver === 'undefined') return () => {};
sizeObserver ??= new ResizeObserver((entries) => {
for (const entry of entries) sizeWatch.get(entry.target)?.();
});
sizeWatch.set(el, onResize);
sizeObserver.observe(el);
return () => {
sizeWatch.delete(el);
sizeObserver?.unobserve(el);
};
}
/**
* The tooltip of a label or category chip: only while the chip cuts its name short,
* since one repeating the words on screen says nothing.
*/
function ChipTooltip({
label,
children,
}: {
label: string;
children: React.ReactElement<React.HTMLAttributes<HTMLElement> & React.RefAttributes<HTMLElement>>;
}): React.ReactElement {
const [cut, setCut] = useState(false);
const ref = useRef<HTMLElement | null>(null);
useEffect(() => {
const text = ref.current?.querySelector('.col-lrow-txt');
if (!text) return undefined;
return watchSize(text, () => setCut(text.scrollWidth > text.clientWidth + 1));
}, []);
return (
<Tooltip label={label} placement="top" disabled={!cut}>
{React.cloneElement(children, { ref })}
</Tooltip>
);
}
/** The room a "+N" chip takes, held free while the labels are fitted. */
const MORE_CHIP = 34;
/**
* A chip's own width, also while the row squeezes it: the part its name is cut short by
* is the name's scroll width over its box, up to the widest the chip may grow.
*/
const naturalWidth = (el: HTMLElement): number => {
const text = el.querySelector<HTMLElement>('.col-lrow-txt');
const full = el.offsetWidth + (text ? text.scrollWidth - text.clientWidth : 0);
const cap = parseFloat(getComputedStyle(el).maxWidth);
return Number.isFinite(cap) ? Math.min(full, cap) : full;
};
/**
* How many labels fit beside the category in `row`: as many as it holds with room left
* for the "+N" chip, and at least one, which then shortens its name. Every label is in
* the row, the ones that do not fit kept out of sight, so each can be measured.
*/
function labelsThatFit(row: HTMLElement): number {
const gap = parseFloat(getComputedStyle(row).columnGap) || 0;
const labels = Array.from(row.querySelectorAll<HTMLElement>('.col-lrow-label'));
const fixed = Array.from(row.querySelectorAll<HTMLElement>('.col-lrow-cat, .col-lrow-div'))
.filter((el) => el.offsetWidth > 0)
.reduce((sum, el) => sum + naturalWidth(el) + gap, 0);
let used = fixed;
let fit = 0;
for (let i = 0; i < labels.length; i++) {
const left = labels.length - i - 1;
const need = used + naturalWidth(labels[i]) + (left > 0 ? gap + MORE_CHIP : 0);
if (need > row.clientWidth && i > 0) break;
used += naturalWidth(labels[i]) + gap;
fit = i + 1;
}
return fit;
}
/**
* A place row's category and labels, on one line that never wraps (#2981). The category
* comes first, then as many labels as the row has room for, then a "+N" chip naming the
* rest in its tooltip, so a wide list shows every label and a narrow one the first.
*/
export default function CollectionPlaceTags({
place,
labels,
}: {
place: CollectionPlace;
labels: CollectionLabel[];
}): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(null);
const [fit, setFit] = useState(labels.length);
const labelKey = labels.map((l) => `${l.id}:${l.name}`).join('|');
const categoryName = place.category?.name ?? null;
// Fitted on every change of the row's width and once more whenever its labels change:
// observing anew hands the observer's first notification to the new labels.
useEffect(() => {
const row = ref.current;
if (!row) return undefined;
return watchSize(row, () => setFit(labelsThatFit(row)));
}, [labelKey, categoryName]);
if (!labels.length && !categoryName) return null;
const shown = Math.min(labels.length, Math.max(1, fit));
const hidden = labels.slice(shown);
return (
<div ref={ref} className="col-lrow-tags">
{categoryName && (
<ChipTooltip label={categoryName}>
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category?.color || '#6366f1' }}>
{createElement(getCategoryIcon(place.category?.icon ?? undefined), { size: 11 })}{' '}
<span className="col-lrow-txt">{categoryName}</span>
</span>
</ChipTooltip>
)}
{labels.map((l, i) => (
<ChipTooltip key={l.id} label={l.name}>
<span
className={i < shown ? 'col-lrow-label' : 'col-lrow-label is-over'}
style={{ ['--label' as string]: l.color || 'var(--accent)' }}
aria-hidden={i < shown ? undefined : true}
>
<span className="col-labelchip-dot" /> <span className="col-lrow-txt">{l.name}</span>
</span>
</ChipTooltip>
))}
{hidden.length > 0 && (
<Tooltip label={hidden.map((l) => l.name).join(', ')} placement="top">
<span className="col-lrow-more">+{hidden.length}</span>
</Tooltip>
)}
<span className="col-lrow-div" aria-hidden />
</div>
);
}
+10 -10
View File
@@ -294,24 +294,24 @@ function CollectionsPageDesktop(): React.ReactElement {
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
<div className="col-toolbar-spacer" />
{c.canEdit && isRealList && (
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<Tags size={14} /> {t('collections.labels.assign')}
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn act" title={t('collections.labels.assign')}>
<Tags size={14} /> <span className="col-selbar-txt">{t('collections.labels.assign')}</span>
</button>
)}
{c.canDelete && (
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<FolderInput size={14} /> {t('collections.moveToList')}
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn act" title={t('collections.moveToList')}>
<FolderInput size={14} /> <span className="col-selbar-txt">{t('collections.moveToList')}</span>
</button>
)}
<button type="button" onClick={() => c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<CopyPlus size={14} /> {t('collections.duplicateToList')}
<button type="button" onClick={() => c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className="col-selbar-btn act" title={t('collections.duplicateToList')}>
<CopyPlus size={14} /> <span className="col-selbar-txt">{t('collections.duplicateToList')}</span>
</button>
<button type="button" onClick={c.openCopyForSelection} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<Copy size={14} /> {t('collections.copyToTrip')}
<button type="button" onClick={c.openCopyForSelection} disabled={c.selectedIds.length === 0} className="col-selbar-btn act" title={t('collections.copyToTrip')}>
<Copy size={14} /> <span className="col-selbar-txt">{t('collections.copyToTrip')}</span>
</button>
{c.canDelete && (
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} className="col-selbar-btn danger">
<Trash2 size={14} /> {t('common.delete')}
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} className="col-selbar-btn act danger" title={t('common.delete')}>
<Trash2 size={14} /> <span className="col-selbar-txt">{t('common.delete')}</span>
</button>
)}
<button type="button" onClick={() => c.setSelectMode(false)} className="col-selbar-btn" aria-label={t('common.cancel')}>
+71 -4
View File
@@ -215,6 +215,13 @@
display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; padding: 10px 14px;
border-radius: var(--r-md); background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--sh-sm);
}
.trek-dash .col-selbar { container-type: inline-size; }
/* Too narrow for every action's words: the actions keep their icon and say what they do
in their tooltip, so the bar stays one line instead of stranding "Delete" below. */
@container (max-width: 1120px) {
.trek-dash .col-selbar-btn.act { padding-inline: 9px; }
.trek-dash .col-selbar-btn.act .col-selbar-txt { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
}
.trek-dash .col-selbar .lbl { font-size: 13px; font-weight: 600; color: var(--ink); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-selbar-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 9px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); transition: background .12s, color .12s; }
.trek-dash .col-selbar-btn:hover { background: var(--surface-2); color: var(--ink); }
@@ -254,6 +261,8 @@
/* ----------------- list view ----------------- */
.trek-dash .col-listview { display: flex; flex-direction: column; gap: 7px; }
/* A row: tile, name and address, then whatever sits at its end. A kept tour keeps one
line: tile, name, its end group. */
.trek-dash .col-lrow {
display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: 14px; align-items: center;
padding: 10px 14px; border-radius: var(--r-md);
@@ -272,8 +281,40 @@
/* 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; }
/* A place row (#2981). Its labels and category used to sit in an end group that could
not shrink, so with two or three long labels the name got one letter or none. Now the
name keeps the room it needs and the labels give way: on a wide list they share the
line and end in an ellipsis; on a narrow one (below 760px) the row has two fixed lines,
name and status on top, labels and category below, still on one line. */
.trek-dash .col-listview { container-type: inline-size; }
.trek-dash .col-lrow-place {
grid-template-columns: 40px minmax(12rem, max-content) minmax(0, 1fr) auto;
grid-template-areas: "tile main tags status";
}
.trek-dash .col-lrow-place > :first-child { grid-area: tile; }
.trek-dash .col-lrow-place > .li { grid-area: main; }
.trek-dash .col-lrow-tags { grid-area: tags; position: relative; display: flex; justify-content: flex-end; align-items: center; gap: 8px; min-width: 0; overflow: hidden; }
/* A label the row has no room for stays in it, out of sight, so it can be measured. */
.trek-dash .col-lrow-tags > .is-over { position: absolute; inset-inline-start: 0; visibility: hidden; pointer-events: none; }
.trek-dash .col-lrow-tags > * { flex-shrink: 0; }
.trek-dash .col-lrow-tags > .col-lrow-label, .trek-dash .col-lrow-tags > .col-lrow-cat { flex-shrink: 1; min-width: 0; }
.trek-dash .col-lrow-more { display: inline-flex; align-items: center; flex-shrink: 0; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; color: var(--ink-3); background: var(--surface-2); cursor: default; }
.trek-dash .col-lrow-status { grid-area: status; display: flex; }
@container (max-width: 760px) {
.trek-dash .col-lrow-place {
grid-template-columns: 40px minmax(0, 1fr) auto;
grid-template-areas: "tile main status" "tile tags tags";
row-gap: 0; align-items: start;
}
.trek-dash .col-lrow-place > :first-child { align-self: center; }
.trek-dash .col-lrow-tags { margin-top: 8px; justify-content: flex-start; }
.trek-dash .col-lrow-tags .col-lrow-div { display: none; }
.trek-dash .col-lrow-tags > .col-lrow-label, .trek-dash .col-lrow-tags > .col-lrow-cat { max-width: 170px; }
}
.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
@@ -318,7 +359,7 @@
/* On the map the tray joins the floating controls, so it takes their glass,
their radius and their shadow instead of the row's flat surface. */
.trek-dash .col-labelfilter.on-map {
pointer-events: auto; max-width: min(52ch, 100%); flex-wrap: nowrap; overflow-x: auto;
pointer-events: auto; max-width: 100%; flex-wrap: nowrap; overflow: hidden;
border-radius: 11px; border-color: var(--glass-border); box-shadow: var(--sh-md);
background: color-mix(in oklch, var(--surface) 92%, transparent);
backdrop-filter: blur(8px) saturate(1.2); -webkit-backdrop-filter: blur(8px) saturate(1.2);
@@ -326,6 +367,17 @@
}
.trek-dash .col-labelfilter.on-map::-webkit-scrollbar { display: none; }
.trek-dash .col-labelfilter.on-map .col-labelchip { flex-shrink: 0; }
/* The strip takes the room the map's top bar leaves between its toggle and the search.
When its labels need more, it folds into one button with the labels as a list; the
hidden copy measures what the labels need, so the strip unfolds once the room is back. */
.trek-dash .col-map-group:not(.right) { flex: 1 1 auto; min-width: 0; }
.trek-dash .col-labelfilter-slot { position: relative; display: flex; flex: 1 1 0; min-width: 0; }
.trek-dash .col-labelfilter-measure { position: absolute; top: 0; inset-inline-start: 0; width: max-content; max-width: none; visibility: hidden; pointer-events: none; }
.trek-dash .col-labelfilter-fold { gap: 7px; padding-inline: 10px 9px; font: inherit; font-size: 12.5px; font-weight: 600; color: var(--ink-2); cursor: pointer; }
.trek-dash .col-labelfilter-fold:hover, .trek-dash .col-labelfilter-fold.open { color: var(--ink); }
.trek-dash .col-labelfilter-active { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: var(--accent-text); background: var(--accent); }
.trek-dash .col-labelfilter-pop { pointer-events: auto; }
.trek-dash .col-labelfilter-manage { color: var(--ink-3); border-top: 1px solid var(--line); border-radius: 0 0 8px 8px; margin-top: 2px; }
.trek-dash .col-labelfilter.on-map .col-labelchip:hover { background: var(--surface-2); }
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 5px 9px; border-radius: 8px; font-size: 12.5px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: transparent; border: 1px solid transparent; transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-labelchip:hover { background: var(--surface); color: var(--ink); }
@@ -358,7 +410,21 @@
over the map are read as a single band when the page is scrolled to the top,
so they share a height and a top offset instead of each having their own. */
.trek-dash { --col-ctl-h: 38px; }
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; }
/* One line on desktop: the dropdowns shorten their labels instead of pushing the sort
control onto a second line. Phones wrap (see the 720px block). */
.trek-dash .col-filterbar { display: flex; flex-wrap: nowrap; align-items: center; gap: 8px; margin-bottom: 12px; min-width: 0; }
.trek-dash .col-filterbar > * { flex-shrink: 0; }
.trek-dash .col-filterbar > .col-filter { flex-shrink: 1; min-width: 0; }
.trek-dash .col-filterbar > .col-filter > .col-filter-btn { max-width: 100%; min-width: 0; }
.trek-dash .col-filterbar > .col-filter > .col-filter-btn > svg { flex-shrink: 0; }
.trek-dash .col-filterbar > .col-filter .col-filter-lbl { min-width: 0; }
/* Narrow: a dropdown left at its default ("All", "Default") keeps its icon and says its
value in its tooltip; one set to a value still shows it. */
.trek-dash .col-filterbar { container-type: inline-size; }
@container (max-width: 540px) {
.trek-dash .col-filter-btn.is-default .col-filter-lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.trek-dash .col-filter-btn.is-default { padding-inline: 9px; }
}
/* Beside the map, the row starts where the map's own top bar does. */
.trek-dash .col-split-list .col-filterbar { margin-top: 12px; }
.trek-dash .col-filter { position: relative; }
@@ -583,12 +649,13 @@
.trek-dash .col-drawer .col-rail-new { min-height: 44px; }
.trek-dash .col-drawer .col-rail-import { min-width: 44px; }
/* enlarge the interactive status badge's tap area without changing its look */
.trek-dash .col-lrow-end > [role="button"] { position: relative; }
.trek-dash .col-lrow-end > [role="button"]::after { content: ''; position: absolute; inset: -9px -6px; }
.trek-dash .col-lrow-status > [role="button"] { position: relative; }
.trek-dash .col-lrow-status > [role="button"]::after { content: ''; position: absolute; inset: -9px -6px; }
/* select action bar: break the bulk actions onto their own line instead of
stranding them behind a growing spacer */
.trek-dash .col-selbar .col-toolbar-spacer { flex-basis: 100%; height: 0; }
.trek-dash .col-filterbar { flex-wrap: wrap; }
}
/* ----------------- mobile rail drawer ----------------- */