[eric] spaces: make the strip scrollable so off-screen spaces are reachable (wheel, drag, chevrons, keep-active-in-view)

This commit is contained in:
ciregenz
2026-07-30 16:44:44 -07:00
parent 108ef03451
commit d5ab082019
2 changed files with 273 additions and 75 deletions
@@ -1,10 +1,11 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { Plus } from 'lucide-react';
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createDashboard, deleteDashboard, duplicateDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
import { ACTIVE_TILE_ATTR, useStripScroll } from './useStripScroll';
// macOS Spaces, one for one: rest the cursor on the top edge and the spaces bar slides down,
// full-size thumbnail tiles with the name beneath, + at the right end to add a space. It stays
@@ -15,6 +16,7 @@ const HOT_ZONE_PX = 3;
const TILE_W = 176;
const TILE_H = 104;
const DISMISS_BELOW_PX = 72;
const EDGE_W = 76;
interface TileMenu {
id: string;
@@ -23,6 +25,41 @@ interface TileMenu {
y: number;
}
interface StripEdgeProps {
side: 'left' | 'right';
visible: boolean;
onNudge: () => void;
}
// The fade says "there is more this way"; the chevron makes it clickable for anyone without a trackpad.
const StripEdge: React.FC<StripEdgeProps> = ({ side, visible, onNudge }) => (
<Box
sx={{
position: 'absolute', top: 0, bottom: 0, [side]: 0, width: EDGE_W,
display: 'flex', alignItems: 'center', justifyContent: side === 'left' ? 'flex-start' : 'flex-end',
px: 0.75, pointerEvents: 'none', zIndex: 1,
opacity: visible ? 1 : 0, transition: 'opacity 180ms ease',
background: `linear-gradient(to ${side === 'left' ? 'right' : 'left'}, rgba(20,18,24,0.9), rgba(20,18,24,0))`,
}}
>
<Box
component="button"
aria-label={side === 'left' ? 'Scroll spaces left' : 'Scroll spaces right'}
onClick={onNudge}
sx={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: '50%', cursor: 'pointer',
pointerEvents: visible ? 'auto' : 'none',
border: '1px solid rgba(255,255,255,0.18)', background: 'rgba(255,255,255,0.12)',
color: 'rgba(255,255,255,0.92)', transition: 'background 140ms',
'&:hover': { background: 'rgba(255,255,255,0.26)' },
}}
>
{side === 'left' ? <ChevronLeft size={17} /> : <ChevronRight size={17} />}
</Box>
</Box>
);
const SpacesStrip: React.FC = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
@@ -44,18 +81,20 @@ const SpacesStrip: React.FC = () => {
[dashboards],
);
const strip = useStripScroll(activeId, list.length, open);
// Mission Control dismissal: while open, watch the cursor globally and close only once it has
// moved a comfortable distance BELOW the bar; hovering within or near the bar never flickers.
// The watcher pauses while a context menu or rename is live, so those can extend below the bar.
// The watcher pauses while a context menu, rename or drag-pan is live, so those can wander below the bar.
React.useEffect(() => {
if (!open || menu || renamingId) return undefined;
if (!open || menu || renamingId || strip.dragging) return undefined;
const onMove = (e: MouseEvent): void => {
const barBottom = barRef.current?.getBoundingClientRect().bottom ?? 0;
if (e.clientY > barBottom + DISMISS_BELOW_PX) setOpen(false);
};
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, [open, menu, renamingId]);
}, [open, menu, renamingId, strip.dragging]);
// The context menu closes on any outside press or Esc, like a native menu.
React.useEffect(() => {
@@ -114,8 +153,7 @@ const SpacesStrip: React.FC = () => {
ref={barRef}
sx={{
position: 'fixed', top: 0, left: 0, right: 0, zIndex: 99999,
display: 'flex', alignItems: 'flex-start', justifyContent: 'center', gap: 2.25,
px: 3, pt: 2, pb: 1.75,
pt: 2, pb: 1.75,
background: 'rgba(22,20,26,0.45)',
backdropFilter: 'blur(30px) saturate(150%)',
WebkitBackdropFilter: 'blur(30px) saturate(150%)',
@@ -125,80 +163,97 @@ const SpacesStrip: React.FC = () => {
pointerEvents: open ? 'auto' : 'none',
}}
>
{list.map((d) => {
const active = d.id === activeId;
const renaming = renamingId === d.id;
return (
<Box
key={d.id}
component="button"
onClick={() => { if (!renaming) { navigate(`/dashboard/${d.id}`); setOpen(false); } }}
onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setMenu({ id: d.id, name: d.name || 'Untitled', x: e.clientX, y: e.clientY }); }}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.75,
p: 0, border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
'&:hover .osw-space-tile': { boxShadow: '0 0 0 3px rgba(255,255,255,0.65), 0 10px 26px rgba(0,0,0,0.4)' },
}}
>
<Box
className="osw-space-tile"
sx={{
width: TILE_W, height: TILE_H, borderRadius: '10px', overflow: 'hidden',
background: 'rgba(255,255,255,0.08)',
boxShadow: active
? '0 0 0 3px rgba(255,255,255,0.9), 0 10px 26px rgba(0,0,0,0.4)'
: '0 0 0 1px rgba(255,255,255,0.14), 0 8px 20px rgba(0,0,0,0.35)',
transition: 'box-shadow 140ms',
}}
>
{d.thumbnail ? (
<Box component="img" src={d.thumbnail} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : (
<Box sx={{ width: '100%', height: '100%', background: 'linear-gradient(135deg, rgba(255,255,255,0.16), rgba(255,255,255,0.05))' }} />
)}
</Box>
{renaming ? (
<Box
component="input"
autoFocus
value={renameValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRenameValue(e.target.value)}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
onKeyDown={(e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter') commitRename(d.id);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => commitRename(d.id)}
sx={{
width: TILE_W - 24, textAlign: 'center', border: '1px solid rgba(255,255,255,0.35)',
borderRadius: '6px', background: 'rgba(0,0,0,0.35)', outline: 'none',
color: 'rgba(255,255,255,0.95)', fontFamily: 'inherit', fontSize: '0.8125rem', py: 0.2,
}}
/>
) : (
<Typography sx={{ color: 'rgba(255,255,255,0.92)', fontSize: '0.8125rem', fontWeight: active ? 600 : 500, maxWidth: TILE_W, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.name || 'Untitled'}
</Typography>
)}
</Box>
);
})}
<Box
component="button"
aria-label="New dashboard"
onClick={addSpace}
ref={strip.scrollRef}
data-spaces-rail
sx={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 64, height: TILE_H, borderRadius: '10px', cursor: 'pointer',
border: '1.5px dashed rgba(255,255,255,0.35)', background: 'rgba(255,255,255,0.05)',
color: 'rgba(255,255,255,0.85)', flexShrink: 0,
transition: 'background 140ms, border-color 140ms',
'&:hover': { background: 'rgba(255,255,255,0.14)', borderColor: 'rgba(255,255,255,0.6)' },
position: 'relative',
display: 'flex', alignItems: 'flex-start', gap: 2.25, px: 3,
// "safe center" centers a short row but falls back to start-aligned once it overflows, so tile one is never clipped out of reach.
justifyContent: 'safe center',
overflowX: 'auto', overflowY: 'hidden', userSelect: 'none',
cursor: strip.dragging ? 'grabbing' : 'default',
scrollbarWidth: 'none', '&::-webkit-scrollbar': { display: 'none' },
}}
>
<Plus size={22} />
{list.map((d) => {
const active = d.id === activeId;
const renaming = renamingId === d.id;
return (
<Box
key={d.id}
component="button"
{...{ [ACTIVE_TILE_ATTR]: active ? 'true' : 'false' }}
onClick={() => { if (!renaming) { navigate(`/dashboard/${d.id}`); setOpen(false); } }}
onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setMenu({ id: d.id, name: d.name || 'Untitled', x: e.clientX, y: e.clientY }); }}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.75, flexShrink: 0,
p: 0, border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
'&:hover .osw-space-tile': { boxShadow: '0 0 0 3px rgba(255,255,255,0.65), 0 10px 26px rgba(0,0,0,0.4)' },
}}
>
<Box
className="osw-space-tile"
sx={{
width: TILE_W, height: TILE_H, borderRadius: '10px', overflow: 'hidden',
background: 'rgba(255,255,255,0.08)',
boxShadow: active
? '0 0 0 3px rgba(255,255,255,0.9), 0 10px 26px rgba(0,0,0,0.4)'
: '0 0 0 1px rgba(255,255,255,0.14), 0 8px 20px rgba(0,0,0,0.35)',
transition: 'box-shadow 140ms',
}}
>
{d.thumbnail ? (
<Box component="img" src={d.thumbnail} alt="" draggable={false} sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : (
<Box sx={{ width: '100%', height: '100%', background: 'linear-gradient(135deg, rgba(255,255,255,0.16), rgba(255,255,255,0.05))' }} />
)}
</Box>
{renaming ? (
<Box
component="input"
autoFocus
value={renameValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRenameValue(e.target.value)}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
onKeyDown={(e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter') commitRename(d.id);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => commitRename(d.id)}
sx={{
width: TILE_W - 24, textAlign: 'center', border: '1px solid rgba(255,255,255,0.35)',
borderRadius: '6px', background: 'rgba(0,0,0,0.35)', outline: 'none', userSelect: 'text',
color: 'rgba(255,255,255,0.95)', fontFamily: 'inherit', fontSize: '0.8125rem', py: 0.2,
}}
/>
) : (
<Typography sx={{ color: 'rgba(255,255,255,0.92)', fontSize: '0.8125rem', fontWeight: active ? 600 : 500, maxWidth: TILE_W, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.name || 'Untitled'}
</Typography>
)}
</Box>
);
})}
<Box
component="button"
aria-label="New dashboard"
onClick={addSpace}
sx={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 64, height: TILE_H, borderRadius: '10px', cursor: 'pointer',
border: '1.5px dashed rgba(255,255,255,0.35)', background: 'rgba(255,255,255,0.05)',
color: 'rgba(255,255,255,0.85)', flexShrink: 0,
transition: 'background 140ms, border-color 140ms',
'&:hover': { background: 'rgba(255,255,255,0.14)', borderColor: 'rgba(255,255,255,0.6)' },
}}
>
<Plus size={22} />
</Box>
</Box>
<StripEdge side="left" visible={strip.overflowing && !strip.atStart} onNudge={() => strip.scrollByPage(-1)} />
<StripEdge side="right" visible={strip.overflowing && !strip.atEnd} onNudge={() => strip.scrollByPage(1)} />
</Box>
{menu && (
<Box
@@ -0,0 +1,143 @@
import React from 'react';
// Mission Control's spaces rail: a vertical wheel scrolls it sideways, a press-drag pans it, and the
// active space is always kept in view. Lives apart from SpacesStrip so the strip stays about tiles.
const DRAG_THRESHOLD_PX = 4;
const EDGE_EPSILON_PX = 2;
const MIN_PAGE_PX = 220;
export const ACTIVE_TILE_ATTR = 'data-space-active';
export interface StripScroll {
scrollRef: React.MutableRefObject<HTMLDivElement | null>;
atStart: boolean;
atEnd: boolean;
overflowing: boolean;
dragging: boolean;
scrollByPage: (dir: -1 | 1) => void;
}
const wantsMotion = (): boolean => !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
export function useStripScroll(activeId: string | null, itemCount: number, animate: boolean): StripScroll {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const [edges, setEdges] = React.useState({ atStart: true, atEnd: true, overflowing: false });
const [dragging, setDragging] = React.useState(false);
const syncEdges = React.useCallback((): void => {
const el = scrollRef.current;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
const next = {
atStart: el.scrollLeft <= EDGE_EPSILON_PX,
atEnd: el.scrollLeft >= max - EDGE_EPSILON_PX,
overflowing: max > EDGE_EPSILON_PX,
};
setEdges((prev) => (
prev.atStart === next.atStart && prev.atEnd === next.atEnd && prev.overflowing === next.overflowing ? prev : next
));
}, []);
React.useEffect(() => {
const el = scrollRef.current;
if (!el) return undefined;
// The canvas zooms on wheel, so anything this rail consumes must stop here and never bubble onward.
const onWheel = (e: WheelEvent): void => {
if (el.scrollWidth - el.clientWidth <= EDGE_EPSILON_PX) return;
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (delta === 0) return;
el.scrollLeft += delta;
e.preventDefault();
e.stopPropagation();
};
el.addEventListener('wheel', onWheel, { passive: false });
el.addEventListener('scroll', syncEdges, { passive: true });
const ro = new ResizeObserver(syncEdges);
ro.observe(el);
return () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('scroll', syncEdges);
ro.disconnect();
};
}, [syncEdges]);
React.useEffect(() => { syncEdges(); }, [syncEdges, itemCount]);
React.useEffect(() => {
const el = scrollRef.current;
if (!el) return undefined;
let startX = 0;
let startLeft = 0;
let moved = false;
let swallowClick = false;
const onMove = (e: PointerEvent): void => {
const dx = e.clientX - startX;
if (!moved) {
if (Math.abs(dx) < DRAG_THRESHOLD_PX) return;
moved = true;
setDragging(true);
}
el.scrollLeft = startLeft - dx;
e.preventDefault();
};
const onUp = (): void => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
if (!moved) return;
swallowClick = true;
setDragging(false);
};
const onDown = (e: PointerEvent): void => {
if (e.button !== 0) return; // right-click belongs to the tile context menu
if ((e.target as HTMLElement).closest('input')) return; // the inline rename field keeps its own caret
swallowClick = false;
startX = e.clientX;
startLeft = el.scrollLeft;
moved = false;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
};
// A pan that ends on a tile still fires a click, so eat exactly that one and let real clicks through.
const onClickCapture = (e: MouseEvent): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopPropagation();
};
el.addEventListener('pointerdown', onDown);
el.addEventListener('click', onClickCapture, true);
return () => {
el.removeEventListener('pointerdown', onDown);
el.removeEventListener('click', onClickCapture, true);
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
}, []);
React.useEffect(() => {
const el = scrollRef.current;
if (!el || !activeId) return;
const target = el.querySelector<HTMLElement>(`[${ACTIVE_TILE_ATTR}="true"]`);
if (!target) return;
const max = el.scrollWidth - el.clientWidth;
if (max <= EDGE_EPSILON_PX) return;
const left = target.offsetLeft;
if (left >= el.scrollLeft && left + target.offsetWidth <= el.scrollLeft + el.clientWidth) return;
const centered = Math.max(0, Math.min(max, left - (el.clientWidth - target.offsetWidth) / 2));
el.scrollTo({ left: centered, behavior: animate && wantsMotion() ? 'smooth' : 'auto' });
}, [activeId, itemCount, animate]);
const scrollByPage = React.useCallback((dir: -1 | 1): void => {
const el = scrollRef.current;
if (!el) return;
const step = Math.max(MIN_PAGE_PX, el.clientWidth * 0.8);
el.scrollBy({ left: dir * step, behavior: wantsMotion() ? 'smooth' : 'auto' });
}, []);
return { scrollRef, ...edges, dragging, scrollByPage };
}