mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
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.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" /> <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>}
|
||||
{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} /> <span className="col-lrow-txt">{place.category.name}</span>
|
||||
</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,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<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;
|
||||
|
||||
/**
|
||||
* 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<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 + 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<HTMLDivElement>(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 (
|
||||
<div ref={ref} className={`col-lrow-tags${measuring ? 'measuring' : ''}`}>
|
||||
{category && CatIcon && (
|
||||
<ChipTooltip label={category.name}>
|
||||
<span className="col-lrow-cat" style={{ ['--cat' as string]: category.color || '#6366f1' }}>
|
||||
<CatIcon size={11} /> <span className="col-lrow-txt">{category.name}</span>
|
||||
</span>
|
||||
</ChipTooltip>
|
||||
)}
|
||||
{shown.map((l) => (
|
||||
<ChipTooltip key={l.id} label={l.name}>
|
||||
<span className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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,12 +261,10 @@
|
||||
|
||||
/* ----------------- 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). */
|
||||
/* 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: flex; flex-wrap: wrap; column-gap: 14px; row-gap: 8px; align-items: center;
|
||||
display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: 14px; 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;
|
||||
@@ -269,16 +274,44 @@
|
||||
.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 > :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 { 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; 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-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, 1fr) minmax(0, max-content) 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; display: flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; }
|
||||
.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; }
|
||||
/* While the row fits its labels every chip keeps its own width (CollectionPlaceTags). */
|
||||
.trek-dash .col-lrow-tags.measuring > * { flex-shrink: 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; }
|
||||
.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; }
|
||||
@@ -326,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);
|
||||
@@ -334,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); }
|
||||
@@ -366,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; }
|
||||
@@ -591,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 ----------------- */
|
||||
|
||||
Reference in New Issue
Block a user