diff --git a/client/e2e/collection-row-names-2981.spec.ts b/client/e2e/collection-row-names-2981.spec.ts index 4c5857193..cbdd76e0e 100644 --- a/client/e2e/collection-row-names-2981.spec.ts +++ b/client/e2e/collection-row-names-2981.spec.ts @@ -14,6 +14,8 @@ 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; @@ -22,6 +24,7 @@ async function expectInside(row: Locator) { } for (const viewport of [ + { width: 1920, height: 1000 }, { width: 1400, height: 900 }, { width: 1024, height: 800 }, ]) { @@ -79,7 +82,17 @@ for (const viewport of [ // 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').count(); + const more = await row + .locator('.col-lrow-more') + .textContent() + .catch(() => null); + 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(); diff --git a/client/src/components/Collections/CollectionFilterBar.tsx b/client/src/components/Collections/CollectionFilterBar.tsx index 266539383..1293b236e 100644 --- a/client/src/components/Collections/CollectionFilterBar.tsx +++ b/client/src/components/Collections/CollectionFilterBar.tsx @@ -35,7 +35,7 @@ function Dropdown({ current, options, onSelect, lead }: { const cur = options.find(o => o.key === current) ?? options[0] return (
- + ) + })} + + ) +} + +/** + * 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(null) + const measureRef = useRef(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) { + 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 ( -
+ 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 && ( + + ) + + const tray = ( +
+ ) + if (!onMap) return tray + + const active = labelFilter.filter(id => labelOptions.some(l => l.id === id)).length + return ( +
+ {!folded ? tray : ( + <> - ) - })} - {canManageLabels && onManageLabels && ( - + {fold.open && ( +
+ {labelOptions.map(l => { + const on = labelFilter.includes(l.id) + return ( + + ) + })} + {canManageLabels && onManageLabels && ( + + )} +
+ )} + )} +
) } diff --git a/client/src/components/Collections/CollectionList.tsx b/client/src/components/Collections/CollectionList.tsx index d7dfc3019..af34ec6fd 100644 --- a/client/src/components/Collections/CollectionList.tsx +++ b/client/src/components/Collections/CollectionList.tsx @@ -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 ? ( {selected && } @@ -67,24 +67,8 @@ export default function CollectionList({
)}
-
- {placeLabels.slice(0, 2).map(l => ( - - {l.name} - - ))} - {placeLabels.length > 2 && l.name).join(', ')}>+{placeLabels.length - 2}} - {place.category?.name && (() => { - const CatIcon = getCategoryIcon(place.category.icon ?? undefined) - return ( - <> - - {place.category.name} - - - - ) - })()} + +
onStatusChange(place.id, next)} t={t} />
diff --git a/client/src/components/Collections/CollectionPlaceTags.tsx b/client/src/components/Collections/CollectionPlaceTags.tsx new file mode 100644 index 000000000..983bcc16f --- /dev/null +++ b/client/src/components/Collections/CollectionPlaceTags.tsx @@ -0,0 +1,145 @@ +import type { CollectionLabel, CollectionPlace } from '@trek/shared'; +import React, { useEffect, useLayoutEffect, 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. + */ +const sizeWatch = new Map 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.RefAttributes>; +}): React.ReactElement { + const [cut, setCut] = useState(false); + const ref = useRef(null); + useEffect(() => { + const text = ref.current?.querySelector('.col-lrow-txt'); + if (!text) return undefined; + return watchSize(text, () => setCut(text.scrollWidth > text.clientWidth + 1)); + }, []); + return ( + + {React.cloneElement(children, { ref })} + + ); +} + +/** The room a "+N" chip takes, held free while the labels are fitted. */ +const MORE_CHIP = 34; + +/** + * How many labels fit beside the category in `row`, measured with every label drawn at + * its own width: as many as the row holds with room left for the "+N" chip, and at + * least one, which then shortens its name. + */ +function labelsThatFit(row: HTMLElement): number { + const gap = parseFloat(getComputedStyle(row).columnGap) || 0; + const labels = Array.from(row.querySelectorAll('.col-lrow-label')); + const fixed = Array.from(row.querySelectorAll('.col-lrow-cat, .col-lrow-div')) + .filter((el) => el.offsetWidth > 0) + .reduce((sum, el) => sum + el.offsetWidth + 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 + labels[i].offsetWidth + (left > 0 ? gap + MORE_CHIP : 0); + if (need > row.clientWidth && i > 0) break; + used += labels[i].offsetWidth + 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(null); + const [fit, setFit] = useState(labels.length); + const [measuring, setMeasuring] = useState(true); + const labelKey = labels.map((l) => `${l.id}:${l.name}`).join('|'); + const category = place.category?.name ? place.category : null; + + useLayoutEffect(() => { + setMeasuring(true); + }, [labelKey, category?.name]); + + useLayoutEffect(() => { + const row = ref.current; + if (!measuring || !row) return; + setFit(labelsThatFit(row)); + setMeasuring(false); + }, [measuring]); + + useEffect(() => { + const row = ref.current; + if (!row) return undefined; + let width = row.clientWidth; + return watchSize(row, () => { + if (row.clientWidth === width) return; + width = row.clientWidth; + setMeasuring(true); + }); + }, []); + + if (!labels.length && !category) return null; + const shown = measuring ? labels : labels.slice(0, Math.max(1, fit)); + const hidden = labels.slice(shown.length); + const CatIcon = category ? getCategoryIcon(category.icon ?? undefined) : null; + + return ( +
+ {category && CatIcon && ( + + + {category.name} + + + )} + {shown.map((l) => ( + + + {l.name} + + + ))} + {hidden.length > 0 && ( + l.name).join(', ')} placement="top"> + +{hidden.length} + + )} + +
+ ); +} diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx index 5118b19e2..2d40dcf99 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -294,24 +294,24 @@ function CollectionsPageDesktop(): React.ReactElement { {t('collections.selectedCount', { count: c.selectedIds.length })}
{c.canEdit && isRealList && ( - )} {c.canDelete && ( - )} - - {c.canDelete && ( - )}