hud chrome: remove Map window, pin top bar, dockable left/right panels (v0.5.0)
CI / build + unit + e2e (pull_request) Successful in 2m18s
CI / build + unit + e2e (pull_request) Successful in 2m18s
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
import { AlignLeft, AlignRight, Maximize2, Minus, Plus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Edge anchor used to place a window on first run (before the user drags it). */
|
||||
@@ -19,6 +19,11 @@ export type FloatAnchor =
|
||||
| 'bottom-center'
|
||||
| 'bottom-right';
|
||||
|
||||
/** A docked column edge: pinned to a screen edge and filling the viewport height. */
|
||||
export type FloatDock = 'left' | 'right';
|
||||
/** A dock mode: a docked edge, or `'float'` (free-floating, the default). */
|
||||
export type FloatDockMode = FloatDock | 'float';
|
||||
|
||||
const STORAGE_PREFIX = 'history.floatwin.v1.';
|
||||
|
||||
// Module-level z-index bookkeeping so sibling windows stack in click order:
|
||||
@@ -26,6 +31,9 @@ const STORAGE_PREFIX = 'history.floatwin.v1.';
|
||||
// raised, the shared counter advances, so the newest on top always wins.
|
||||
const Z_BASE = 10;
|
||||
const Z_STEP = 10;
|
||||
// A pinned window always floats above every docked/floating window, no matter
|
||||
// how many times those are raised.
|
||||
const Z_PINNED = 100000;
|
||||
let zSeq = 0;
|
||||
let zTopId: string | null = null;
|
||||
/** Returns the new z-index to use, or -1 if this window is already front-most. */
|
||||
@@ -118,8 +126,31 @@ function clampPos(x: number, y: number, w: number, h: number): Pos {
|
||||
export interface FloatingWindowProps {
|
||||
/** Stable id — used for the localStorage key and as a data-attribute. */
|
||||
id: string;
|
||||
/** First-run placement. The last dragged position (persisted) takes over. */
|
||||
anchor: FloatAnchor;
|
||||
/** Optional `data-testid` on the window root, for e2e targeting. */
|
||||
dataTestId?: string;
|
||||
/**
|
||||
* First-run placement for a free-floating window; the last dragged position
|
||||
* (persisted) takes over. Ignored when `pinned` or `dock` is set.
|
||||
*/
|
||||
anchor?: FloatAnchor;
|
||||
/**
|
||||
* Pin the window to the top-center of the viewport: a fixed, non-movable bar
|
||||
* a few px from the top that re-centers automatically on resize, always
|
||||
* floats above docked/floating panels, and never reads/writes a persisted
|
||||
* position. Used for the slim header.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
/**
|
||||
* Dock the window into a full-height left or right column: non-movable,
|
||||
* body scrolls, never reads/writes the persisted free-floating position.
|
||||
* Absent (`undefined`) = free-floating as before.
|
||||
*/
|
||||
dock?: FloatDock;
|
||||
/**
|
||||
* When provided, render dock controls (left / right / float) in the title bar
|
||||
* and report the chosen mode back to the parent, which persists it.
|
||||
*/
|
||||
onDockChange?: (mode: FloatDockMode) => void;
|
||||
/** Fixed window width in px (content is sized to it). Omit to fit content. */
|
||||
width?: number;
|
||||
/** Extra px nudged away from the anchor position (e.g. clear the map attribution). */
|
||||
@@ -128,7 +159,7 @@ export interface FloatingWindowProps {
|
||||
title?: ReactNode;
|
||||
/** Optional icon before the title. */
|
||||
titleIcon?: ReactNode;
|
||||
/** Right-side title-bar content, rendered before the collapse button. */
|
||||
/** Right-side title-bar content, rendered before the dock/collapse controls. */
|
||||
titleExtra?: ReactNode;
|
||||
/** Render the collapse toggle (default true). */
|
||||
collapsible?: boolean;
|
||||
@@ -143,16 +174,27 @@ export interface FloatingWindowProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* A draggable, collapsible, position-persisted window that floats over the
|
||||
* full-screen map. Dragging is limited to the title bar (via pointer capture)
|
||||
* so inputs and buttons in the body keep working; the window is clamped so it
|
||||
* can never be dragged fully off-screen, its position is remembered in
|
||||
* localStorage, clicking any part of it raises it above siblings, and the
|
||||
* collapse toggle shrinks it to just its title bar.
|
||||
* A window that floats over the full-screen map. Three mutually-exclusive
|
||||
* modes, set via props (absent = free-floating):
|
||||
* • **free-floating** (default) — draggable by the title bar (pointer
|
||||
* capture), clamped on-screen, position persisted in localStorage, raised
|
||||
* above siblings on click, collapsible.
|
||||
* • **pinned** — a fixed, non-movable bar centered at the top of the
|
||||
* viewport a few px down; re-centers on resize; always above other
|
||||
* windows; no position persisted.
|
||||
* • **docked** (`left`/`right`) — a non-movable, full-height column pinned
|
||||
* to a screen edge whose body scrolls; the persisted free-floating
|
||||
* position is neither read nor written while docked.
|
||||
* When `onDockChange` is provided, small dock/left/right/float controls render
|
||||
* in the title bar and report the chosen mode back to the parent.
|
||||
*/
|
||||
export default function FloatingWindow({
|
||||
id,
|
||||
anchor,
|
||||
dataTestId,
|
||||
anchor = 'top-center',
|
||||
pinned = false,
|
||||
dock,
|
||||
onDockChange,
|
||||
width,
|
||||
offset,
|
||||
title,
|
||||
@@ -165,6 +207,12 @@ export default function FloatingWindow({
|
||||
bodyClassName,
|
||||
children,
|
||||
}: FloatingWindowProps) {
|
||||
const isPinned = pinned;
|
||||
const isDocked = dock === 'left' || dock === 'right';
|
||||
// Free-floating is the only mode that drags, persists a position, and needs
|
||||
// viewport measurement/clamping. Pinned and docked are CSS-positioned.
|
||||
const isFloating = !isPinned && !isDocked;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const sizeRef = useRef<{ w: number; h: number } | null>(null);
|
||||
const dragRef = useRef<{ dx: number; dy: number; pointerId: number } | null>(null);
|
||||
@@ -177,9 +225,11 @@ export default function FloatingWindow({
|
||||
setPosState(p);
|
||||
};
|
||||
|
||||
// First paint: measure, then place (restored position or anchor) and clamp.
|
||||
// Runs before paint (useLayoutEffect), so there is no visible flash.
|
||||
// First paint (free-floating only): measure, then place (restored position
|
||||
// or anchor) and clamp. Runs before paint (useLayoutEffect), so no flash.
|
||||
// Re-runs when the mode changes so a window returning from docked re-places.
|
||||
useLayoutEffect(() => {
|
||||
if (!isFloating) return;
|
||||
if (posRef.current) return;
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
@@ -195,11 +245,12 @@ export default function FloatingWindow({
|
||||
);
|
||||
posRef.current = p;
|
||||
setPosState(p);
|
||||
}, [id, anchor]);
|
||||
}, [id, anchor, offset, isFloating]);
|
||||
|
||||
// Re-measure + re-clamp whenever the body toggles hidden (expand can push a
|
||||
// bottom-anchored window off the bottom edge).
|
||||
// bottom-anchored window off the bottom edge). Free-floating only.
|
||||
useLayoutEffect(() => {
|
||||
if (!isFloating) return;
|
||||
const el = rootRef.current;
|
||||
const p = posRef.current;
|
||||
if (!el || !p) return;
|
||||
@@ -208,10 +259,11 @@ export default function FloatingWindow({
|
||||
sizeRef.current = { w: rect.width, h: rect.height };
|
||||
const c = clampPos(p.x, p.y, rect.width, rect.height);
|
||||
if (c.x !== p.x || c.y !== p.y) setPos(c);
|
||||
}, [collapsed]);
|
||||
}, [collapsed, isFloating]);
|
||||
|
||||
// Keep the window on-screen when the viewport shrinks.
|
||||
// Keep a free-floating window on-screen when the viewport shrinks.
|
||||
useEffect(() => {
|
||||
if (!isFloating) return;
|
||||
const onResize = () => {
|
||||
const p = posRef.current;
|
||||
const s = sizeRef.current;
|
||||
@@ -221,7 +273,7 @@ export default function FloatingWindow({
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
}, [isFloating]);
|
||||
|
||||
const onHandleDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -258,18 +310,44 @@ export default function FloatingWindow({
|
||||
if (p) savePos(id, p.x, p.y);
|
||||
};
|
||||
|
||||
const style: CSSProperties = {
|
||||
zIndex: z,
|
||||
...(pos ? { left: pos.x, top: pos.y } : {}),
|
||||
};
|
||||
// Root placement: pinned → centered top bar; docked → full-height edge
|
||||
// column; free-floating → last/initial pixel position (hidden until measured).
|
||||
const rootStyle: CSSProperties = isPinned
|
||||
? { zIndex: Z_PINNED, top: 8, left: '50%', transform: 'translateX(-50%)' }
|
||||
: isDocked
|
||||
? {
|
||||
zIndex: z,
|
||||
top: 0,
|
||||
height: '100vh',
|
||||
...(dock === 'left' ? { left: 0 } : { right: 0 }),
|
||||
}
|
||||
: { zIndex: z, ...(pos ? { left: pos.x, top: pos.y } : {}) };
|
||||
|
||||
// The title bar is the drag handle only in free-floating mode.
|
||||
const titleBarHandlers = isFloating
|
||||
? {
|
||||
onPointerDown: onHandleDown,
|
||||
onPointerMove: onHandleMove,
|
||||
onPointerUp: endDrag,
|
||||
onPointerCancel: endDrag,
|
||||
}
|
||||
: {};
|
||||
|
||||
// Docked: the body fills the remaining column height and scrolls, overriding
|
||||
// any max-height the caller set for the floating layout.
|
||||
const bodyClass = isDocked
|
||||
? cn('min-h-0 flex-1 overflow-y-auto', collapsed && 'hidden')
|
||||
: cn('min-h-0', collapsed && 'hidden', bodyClassName);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-float-window={id}
|
||||
style={style}
|
||||
className={cn('fixed', !pos && 'pointer-events-none opacity-0')}
|
||||
data-testid={dataTestId}
|
||||
style={rootStyle}
|
||||
className={cn('fixed', isFloating && !pos && 'pointer-events-none opacity-0')}
|
||||
onPointerDown={() => {
|
||||
if (isPinned) return;
|
||||
const nz = nextZ(id);
|
||||
if (nz > 0) setZ(nz);
|
||||
}}
|
||||
@@ -277,21 +355,24 @@ export default function FloatingWindow({
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-border/70 bg-slate-900/80 shadow-2xl shadow-black/50 backdrop-blur-md',
|
||||
isDocked && 'flex h-full flex-col',
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
width != null
|
||||
? { width, maxWidth: '96vw' }
|
||||
: { width: 'fit-content', maxWidth: '96vw' }
|
||||
isDocked
|
||||
? { width: width ?? 'fit-content', maxWidth: '96vw' }
|
||||
: width != null
|
||||
? { width, maxWidth: '96vw' }
|
||||
: { width: 'fit-content', maxWidth: '96vw' }
|
||||
}
|
||||
>
|
||||
{title != null && (
|
||||
<div
|
||||
className="flex cursor-grab touch-none select-none items-center gap-2 border-b border-border/60 bg-white/[0.03] px-2.5 py-1.5 active:cursor-grabbing"
|
||||
onPointerDown={onHandleDown}
|
||||
onPointerMove={onHandleMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
className={cn(
|
||||
'flex shrink-0 touch-none select-none items-center gap-2 border-b border-border/60 bg-white/[0.03] px-2.5 py-1.5',
|
||||
isFloating && 'cursor-grab active:cursor-grabbing',
|
||||
)}
|
||||
{...titleBarHandlers}
|
||||
>
|
||||
{titleIcon && (
|
||||
<span className="shrink-0 text-muted-foreground" aria-hidden>
|
||||
@@ -302,6 +383,49 @@ export default function FloatingWindow({
|
||||
{title}
|
||||
</span>
|
||||
{titleExtra}
|
||||
{onDockChange && (
|
||||
<span className="flex shrink-0 items-center gap-0.5" data-no-drag>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dock-left"
|
||||
aria-label="Dock to the left edge"
|
||||
title="Dock to the left edge"
|
||||
className={cn(
|
||||
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
|
||||
dock === 'left' && 'bg-white/10 text-foreground',
|
||||
)}
|
||||
onClick={() => onDockChange('left')}
|
||||
>
|
||||
<AlignLeft className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dock-right"
|
||||
aria-label="Dock to the right edge"
|
||||
title="Dock to the right edge"
|
||||
className={cn(
|
||||
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
|
||||
dock === 'right' && 'bg-white/10 text-foreground',
|
||||
)}
|
||||
onClick={() => onDockChange('right')}
|
||||
>
|
||||
<AlignRight className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dock-float"
|
||||
aria-label="Float the window"
|
||||
title="Float (undock)"
|
||||
className={cn(
|
||||
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
|
||||
!isDocked && 'bg-white/10 text-foreground',
|
||||
)}
|
||||
onClick={() => onDockChange('float')}
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{collapsibleProp && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -315,9 +439,7 @@ export default function FloatingWindow({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children != null && (
|
||||
<div className={cn('min-h-0', collapsed && 'hidden', bodyClassName)}>{children}</div>
|
||||
)}
|
||||
{children != null && <div className={bodyClass}>{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user