feat(collections): list+map split, taller rail, list-menu popover fix (#1081)

- List view splits into a scrollable list + a sticky map on wide screens;
  clicking a place pans/highlights it on the map (single selectedPlaceId, no
  inspector over the map). Narrow screens keep the single-column list.
- Keep the list rail at least as tall as the hero (measure the hero via a
  small useElementSize hook and feed its height as the rail's min-height).
- List row kebab menu: portal the menu/colour popover to the body so the
  rail's overflow + backdrop-filter can't clip it ("renders only in the
  module"), and fade the place count on hover so the kebab stops overlapping it.
This commit is contained in:
Maurice
2026-07-01 09:44:11 +02:00
committed by Maurice
parent 4e478722c3
commit 74cace7c8f
5 changed files with 181 additions and 77 deletions
+81 -40
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import ReactDOM from 'react-dom'
import { Plus, Layers, MoreHorizontal, Pencil, Trash2, Users, Check, Palette } from 'lucide-react'
import type { Collection } from '@trek/shared'
import type { TranslationFn } from '../../types'
@@ -46,19 +47,47 @@ function ListRow({
}: ListRowProps): React.ReactElement {
const [menuOpen, setMenuOpen] = useState(false)
const [colorOpen, setColorOpen] = useState(false)
// Anchor rect for the portalled popover (fixed to the viewport). The popover
// is portalled to document.body so the rail's `overflow-y:auto` +
// `backdrop-filter` can't clip it.
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null)
const rowRef = useRef<HTMLDivElement>(null)
const btnRef = useRef<HTMLButtonElement>(null)
const open = menuOpen || colorOpen
// Owner or accepted member may rename/recolour any accessible list; only the
// owner may delete it.
const canDelete = list.is_owner !== false
const openMenu = (e: React.MouseEvent) => {
e.stopPropagation()
if (open) { setMenuOpen(false); setColorOpen(false); return }
const r = btnRef.current?.getBoundingClientRect()
if (r) setAnchor({ top: r.bottom + 6, right: Math.max(8, window.innerWidth - r.right) })
setColorOpen(false)
setMenuOpen(true)
}
useEffect(() => {
if (!menuOpen && !colorOpen) return
if (!open) return
const close = () => { setMenuOpen(false); setColorOpen(false) }
const onDown = (e: MouseEvent) => {
if (rowRef.current && !rowRef.current.contains(e.target as Node)) { setMenuOpen(false); setColorOpen(false) }
const el = e.target as HTMLElement
if (rowRef.current?.contains(el) || el.closest?.('[data-list-pop]')) return
close()
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [menuOpen, colorOpen])
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close() }
document.addEventListener('mousedown', onDown, true)
document.addEventListener('keydown', onKey)
window.addEventListener('resize', close)
// Any scroll (page or rail) invalidates the anchor rect → just close.
window.addEventListener('scroll', close, true)
return () => {
document.removeEventListener('mousedown', onDown, true)
document.removeEventListener('keydown', onKey)
window.removeEventListener('resize', close)
window.removeEventListener('scroll', close, true)
}
}, [open])
if (editing) {
return (
@@ -75,54 +104,66 @@ function ListRow({
)
}
const popover = open && anchor && ReactDOM.createPortal(
<div
data-list-pop
className="fixed z-[300] min-w-[176px] p-1.5 rounded-xl bg-surface-card border border-edge shadow-dropdown"
style={{ top: anchor.top, right: anchor.right }}
>
{menuOpen && !colorOpen && (
<>
<button type="button" onClick={() => { setMenuOpen(false); onStartRename(list.id, list.name) }} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
<Pencil size={14} /> {t('collections.editList')}
</button>
<button type="button" onClick={() => setColorOpen(true)} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
<Palette size={14} /> {t('collections.listColor')}
</button>
{canDelete && (
<button type="button" onClick={() => { setMenuOpen(false); onRequestDelete(list.id) }} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-danger hover:bg-danger-soft">
<Trash2 size={14} /> {t('collections.deleteList')}
</button>
)}
</>
)}
{colorOpen && (
<div className="grid grid-cols-4 gap-1.5 p-1">
{SWATCHES.map(col => (
<button
key={col}
type="button"
onClick={() => { onSetColor(list.id, col); setColorOpen(false); setMenuOpen(false) }}
className="w-7 h-7 rounded-full flex items-center justify-center text-white"
style={{ background: col }}
aria-label={col}
>
{list.color === col && <Check size={13} strokeWidth={3} />}
</button>
))}
</div>
)}
</div>,
document.body,
)
return (
<div ref={rowRef} className="col-row">
<div ref={rowRef} className={`col-row${open ? ' menu-open' : ''}`}>
<button type="button" onClick={() => onSelect(list.id)} className={`col-row-btn${active ? ' on' : ''}`}>
<span className="dot" style={{ background: list.color || '#6366f1' }} />
<span className="nm">{list.name}</span>
<span className="ct">{list.place_count ?? 0}</span>
</button>
<button
ref={btnRef}
type="button"
onClick={e => { e.stopPropagation(); setMenuOpen(o => !o); setColorOpen(false) }}
onClick={openMenu}
className="col-row-menu"
aria-label={t('collections.listMenu')}
aria-haspopup="menu"
aria-expanded={open}
>
<MoreHorizontal size={15} />
</button>
{menuOpen && !colorOpen && (
<div className="col-pop">
<button type="button" onClick={() => { setMenuOpen(false); onStartRename(list.id, list.name) }} className="col-pop-item">
<Pencil size={14} /> {t('collections.editList')}
</button>
<button type="button" onClick={() => setColorOpen(true)} className="col-pop-item">
<Palette size={14} /> {t('collections.listColor')}
</button>
{canDelete && (
<button type="button" onClick={() => { setMenuOpen(false); onRequestDelete(list.id) }} className="col-pop-item danger">
<Trash2 size={14} /> {t('collections.deleteList')}
</button>
)}
</div>
)}
{colorOpen && (
<div className="col-pop">
<div className="col-pop-swatches">
{SWATCHES.map(c => (
<button
key={c}
type="button"
onClick={() => { onSetColor(list.id, c); setColorOpen(false); setMenuOpen(false) }}
className="col-swatch"
style={{ background: c }}
aria-label={c}
>
{list.color === c && <Check size={13} strokeWidth={3} />}
</button>
))}
</div>
</div>
)}
{popover}
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { useCallback, useRef, useState } from 'react'
/**
* Observe an element's border-box size via a callback ref. Returns the ref to
* attach plus the current width/height. The ResizeObserver is (re)connected
* whenever the ref is attached and disconnected when it detaches (React calls
* the callback ref with null on unmount), so no cleanup effect is needed —
* which keeps this usable straight from a page's data hook.
*/
export function useElementSize<T extends HTMLElement = HTMLElement>(): {
ref: (el: T | null) => void
width: number
height: number
} {
const [size, setSize] = useState({ width: 0, height: 0 })
const obs = useRef<ResizeObserver | null>(null)
const ref = useCallback((el: T | null) => {
obs.current?.disconnect()
obs.current = null
if (!el) return
const measure = () => setSize({ width: el.offsetWidth, height: el.offsetHeight })
measure()
obs.current = new ResizeObserver(measure)
obs.current.observe(el)
}, [])
return { ref, width: size.width, height: size.height }
}
+34 -20
View File
@@ -68,6 +68,11 @@ export default function CollectionsPage(): React.ReactElement {
const noLists = !c.loading && c.collections.length === 0
const showSelect = !c.isAllSaved && c.activeCollection != null
// On a wide screen the list view splits into list + persistent map; clicking a
// place pans the map instead of opening the (map-covering) inspector.
const mappable = mappablePlaces(c.visiblePlaces)
const inSplit = c.isWide && c.view === 'list' && mappable.length > 0
let body: React.ReactElement
if (c.placesLoading && !hasPlaces) {
body = <div className="col-loading"><div className="col-spinner" /></div>
@@ -89,7 +94,7 @@ export default function CollectionsPage(): React.ReactElement {
/>
)
} else if (c.view === 'list') {
body = (
const listEl = (
<CollectionList
places={c.visiblePlaces}
selectedPlaceId={c.selectedPlaceId}
@@ -101,8 +106,15 @@ export default function CollectionsPage(): React.ReactElement {
t={t}
/>
)
body = inSplit ? (
<div className="col-split">
<div className="col-split-list">{listEl}</div>
<div className="col-split-map">
<CollectionMap places={mappable} selectedPlaceId={c.selectedPlaceId} onOpenPlace={c.setSelectedPlaceId} dark={c.dark} />
</div>
</div>
) : listEl
} else {
const mappable = mappablePlaces(c.visiblePlaces)
body = mappable.length === 0 ? (
<EmptyState icon={<Search size={26} />} title={t('collections.empty.noMatchTitle')} text={t('collections.empty.noMatchText')} />
) : (
@@ -138,7 +150,7 @@ export default function CollectionsPage(): React.ReactElement {
<Navbar />
<div className="trek-dash col-root">
<div className="col-page">
<aside className="col-rail">{rail}</aside>
<aside className="col-rail" style={{ minHeight: c.heroHeight || undefined }}>{rail}</aside>
<div className="col-body">
{noLists ? (
@@ -154,22 +166,24 @@ export default function CollectionsPage(): React.ReactElement {
/>
) : (
<>
<CollectionHero
eyebrow={eyebrow}
title={title}
color={heroColor}
coverImage={heroCover}
counts={c.counts}
statusFilter={c.statusFilter}
onStatusFilter={c.setStatusFilter}
members={c.members}
canShare={c.canShare}
isOwner={c.isOwner}
shareMemberCount={c.shareMemberCount}
onShare={() => c.setShowShare(true)}
onNewList={() => c.setShowNewList(true)}
t={t}
/>
<div ref={c.heroRef}>
<CollectionHero
eyebrow={eyebrow}
title={title}
color={heroColor}
coverImage={heroCover}
counts={c.counts}
statusFilter={c.statusFilter}
onStatusFilter={c.setStatusFilter}
members={c.members}
canShare={c.canShare}
isOwner={c.isOwner}
shareMemberCount={c.shareMemberCount}
onShare={() => c.setShowShare(true)}
onNewList={() => c.setShowNewList(true)}
t={t}
/>
</div>
<div className="col-toolbar">
<button type="button" className="col-rail-toggle" onClick={() => c.setMobileRailOpen(true)}>
@@ -233,7 +247,7 @@ export default function CollectionsPage(): React.ReactElement {
.trek-dash (it's a shared component on the app's own tokens). The outer
layer is click-through so the grid behind stays interactive; only the
floating card re-enables pointer events. */}
{c.detailPlace && (
{c.detailPlace && !inSplit && (
<div className="fixed inset-0 z-[150]" style={{ pointerEvents: 'none', paddingTop: 'var(--nav-h)' }}>
<div className="relative w-full h-full">
<div style={{ pointerEvents: 'auto' }}>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useTranslation } from '../../i18n'
import { useElementSize } from '../../hooks/useElementSize'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
@@ -27,6 +28,19 @@ export function useCollections() {
const dm = useSettingsStore(s => s.settings.dark_mode)
const dark = dm === true || dm === 'dark' || (dm === 'auto' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Desktop breakpoint for the list+map split (list view only). Below it the
// list stays single-column and the map is its own view.
const [isWide, setIsWide] = useState(() => typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches)
useEffect(() => {
const mq = window.matchMedia('(min-width: 1024px)')
const on = () => setIsWide(mq.matches)
mq.addEventListener('change', on)
return () => mq.removeEventListener('change', on)
}, [])
// Measure the hero so the list rail can be kept at least as tall as it.
const hero = useElementSize<HTMLDivElement>()
const store = useCollectionStore()
const {
collections, activeId, places, members, incomingInvites,
@@ -277,6 +291,7 @@ export function useCollections() {
return {
t, language, dark, navigate,
isWide, heroRef: hero.ref, heroHeight: hero.height,
// store data
collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner,
canShare, shareMemberCount,
+22 -17
View File
@@ -56,13 +56,18 @@
.trek-dash .col-row-btn .ico { color: var(--ink-3); flex-shrink: 0; display: grid; place-items: center; }
.trek-dash .col-row-btn.on .ico { color: var(--ink-2); }
.trek-dash .col-row-btn .nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-row-btn .ct { font-size: 11.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; flex-shrink: 0; }
.trek-dash .col-row-btn .ct { font-size: 11.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; flex-shrink: 0; transition: opacity .12s; }
/* On hover / while its menu is open the count fades out (keeping its layout
space) so the kebab button can take the same slot without overlapping it. */
.trek-dash .col-row:hover .col-row-btn .ct,
.trek-dash .col-row.menu-open .col-row-btn .ct { opacity: 0; }
.trek-dash .col-row-menu {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
width: 26px; height: 26px; border-radius: 7px; display: grid; place-items: center;
color: var(--ink-3); opacity: 0; transition: opacity .12s, background .12s;
}
.trek-dash .col-row:hover .col-row-menu { opacity: 1; }
.trek-dash .col-row:hover .col-row-menu,
.trek-dash .col-row.menu-open .col-row-menu { opacity: 1; }
.trek-dash .col-row-menu:hover { background: var(--bg-2); color: var(--ink); }
/* rename inline input */
@@ -72,21 +77,9 @@
font-size: 13.5px; font-weight: 500; outline: none;
}
/* row popover (menu / colour) */
.trek-dash .col-pop {
position: absolute; right: 4px; top: calc(50% + 16px); z-index: 30; min-width: 168px;
padding: 5px; border-radius: var(--r-sm); background: var(--surface); border: 1px solid var(--line);
box-shadow: var(--sh-lg);
}
.trek-dash .col-pop-item {
display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 10px;
border-radius: 9px; font-size: 12.5px; color: var(--ink-2); font-weight: 500; transition: background .12s;
}
.trek-dash .col-pop-item:hover { background: var(--surface-2); color: var(--ink); }
.trek-dash .col-pop-item.danger { color: oklch(0.6 0.2 25); }
.dark .trek-dash .col-pop-item.danger { color: oklch(0.72 0.17 25); }
.trek-dash .col-pop-swatches { display: grid; grid-template-columns: repeat(4, 1fr); gap: 7px; padding: 6px; }
.trek-dash .col-swatch { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; color: #fff; }
/* The list row's kebab menu / colour picker is a Tailwind-styled popover
portalled to document.body (see ListsRail), so nothing here — keeping it in
the rail would let the rail's overflow + backdrop-filter clip it. */
/* incoming-invite card */
.trek-dash .col-invite { padding: 11px; border-radius: var(--r-sm); background: var(--surface-2); margin: 2px 0; }
@@ -260,6 +253,18 @@
height: calc(100vh - var(--nav-h) - 320px); min-height: 380px;
}
/* list + map split (list view on wide screens): scrollable list left, sticky
map right. Clicking a place pans the map (selectedPlaceId drives it). Only
rendered above 1024px (the page gates it in JS), so no mobile override needed. */
.trek-dash .col-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 1.06fr); gap: 20px; align-items: start; }
.trek-dash .col-split-list { min-width: 0; }
.trek-dash .col-split-map {
position: sticky; top: calc(var(--nav-h) + 22px);
height: calc(100vh - var(--nav-h) - 210px); min-height: 440px;
border-radius: var(--r-xl); overflow: hidden;
border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow);
}
/* The status pill itself (idea/want/visited) is the StatusBadge component,
styled with utility classes so it also works outside .trek-dash (the place
inspector). It is a role=button span, not a <button>, so the global