mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user