diff --git a/client/src/components/Collections/ListsRail.tsx b/client/src/components/Collections/ListsRail.tsx index 9f17e8ba4..5978d9db4 100644 --- a/client/src/components/Collections/ListsRail.tsx +++ b/client/src/components/Collections/ListsRail.tsx @@ -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(null) + const btnRef = useRef(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( +
+ {menuOpen && !colorOpen && ( + <> + + + {canDelete && ( + + )} + + )} + {colorOpen && ( +
+ {SWATCHES.map(col => ( + + ))} +
+ )} +
, + document.body, + ) + return ( -
+
- {menuOpen && !colorOpen && ( -
- - - {canDelete && ( - - )} -
- )} - {colorOpen && ( -
-
- {SWATCHES.map(c => ( - - ))} -
-
- )} + {popover}
) } diff --git a/client/src/hooks/useElementSize.ts b/client/src/hooks/useElementSize.ts new file mode 100644 index 000000000..03007a34b --- /dev/null +++ b/client/src/hooks/useElementSize.ts @@ -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(): { + ref: (el: T | null) => void + width: number + height: number +} { + const [size, setSize] = useState({ width: 0, height: 0 }) + const obs = useRef(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 } +} diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx index ebe248008..6c746eba1 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -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 =
@@ -89,7 +94,7 @@ export default function CollectionsPage(): React.ReactElement { /> ) } else if (c.view === 'list') { - body = ( + const listEl = ( ) + body = inSplit ? ( +
+
{listEl}
+
+ +
+
+ ) : listEl } else { - const mappable = mappablePlaces(c.visiblePlaces) body = mappable.length === 0 ? ( } title={t('collections.empty.noMatchTitle')} text={t('collections.empty.noMatchText')} /> ) : ( @@ -138,7 +150,7 @@ export default function CollectionsPage(): React.ReactElement {
- +
{noLists ? ( @@ -154,22 +166,24 @@ export default function CollectionsPage(): React.ReactElement { /> ) : ( <> - c.setShowShare(true)} - onNewList={() => c.setShowNewList(true)} - t={t} - /> +
+ c.setShowShare(true)} + onNewList={() => c.setShowNewList(true)} + t={t} + /> +