Files
fusion/packages/dashboard/app/components/RightDockExpandModal.tsx
gsxdsm 7ee36f26e5 FN-6913: localize right dock labels
Route right-dock accessibility labels through the app i18n namespace.\n\n- Replace hard-coded right dock and pop-out aria/title copy with translated keys and English fallbacks.\n- Add rightDock locale entries across app locale catalogs and typed resources.\n- Extend RightDock tests to cover localized dock and expanded modal affordance labels.\n\nFiles changed:\n packages/dashboard/app/components/RightDock.tsx          | 16 +++++++++++-----\n .../dashboard/app/components/RightDockExpandModal.tsx    | 12 +++++++++---\n .../app/components/__tests__/RightDock.test.tsx          | 15 ++++++++++++++-\n packages/i18n/locales/en/app.json                        | 11 +++++++++++\n packages/i18n/locales/es/app.json                        | 11 +++++++++++\n packages/i18n/locales/fr/app.json                        | 11 +++++++++++\n packages/i18n/locales/ko/app.json                        | 11 +++++++++++\n packages/i18n/locales/zh-CN/app.json                     | 11 +++++++++++\n packages/i18n/locales/zh-TW/app.json                     | 11 +++++++++++\n packages/i18n/src/resources.d.ts                         | 11 +++++++++++\n 10 files changed, 111 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-6913

Fusion-Task-Lineage: 1c07da14-d8a3-42ba-8c93-843c3fe1fa63
2026-06-22 19:35:26 -07:00

358 lines
17 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Maximize2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { findOverflowViewEntry, type OverflowViewEntry, type OverflowViewKey, type OverflowViewRenderProps, type OverflowViewVisibilityOptions } from "./overflowViewRegistry";
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
import "./RightDock.css";
const RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY = "fusion:right-dock-expand-modal-size";
const RIGHT_DOCK_EXPAND_MODAL_POSITION_STORAGE_KEY = "fusion:right-dock-expand-modal-position";
/*
FNXC:RightDock 2026-06-22-17:40:
The right-dock pop-out is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window. The user positions it anywhere on screen and keeps using the app behind it: NO background dimming/blur, and the overlay is `pointer-events: none` so behind-clicks pass through (only the panel re-enables `pointer-events: auto`). Because behind-clicks never reach the overlay there is no overlay click-to-dismiss; the explicit header close button is the only dismissal. This mirrors TerminalModal's floating mode (drag the header, resize from the corners, rAF-batched updates, a single dragTeardownRef invoked on pointerup/pointercancel AND on unmount so no document listeners leak).
*/
const EXPAND_DEFAULT_WIDTH = 960;
const EXPAND_DEFAULT_HEIGHT = 600;
const EXPAND_MIN_WIDTH = 360;
const EXPAND_MIN_HEIGHT = 280;
const EXPAND_VIEWPORT_PADDING = 16;
interface ExpandSize {
width: number;
height: number;
}
interface ExpandPosition {
x: number;
y: number;
}
function clampExpandSize(size: ExpandSize): ExpandSize {
if (typeof window === "undefined") return size;
return {
width: Math.min(Math.max(size.width, EXPAND_MIN_WIDTH), Math.max(EXPAND_MIN_WIDTH, window.innerWidth - EXPAND_VIEWPORT_PADDING * 2)),
height: Math.min(Math.max(size.height, EXPAND_MIN_HEIGHT), Math.max(EXPAND_MIN_HEIGHT, window.innerHeight - EXPAND_VIEWPORT_PADDING * 2)),
};
}
function clampExpandPosition(position: ExpandPosition, size: ExpandSize): ExpandPosition {
if (typeof window === "undefined") return position;
return {
x: Math.min(Math.max(position.x, EXPAND_VIEWPORT_PADDING), Math.max(EXPAND_VIEWPORT_PADDING, window.innerWidth - size.width - EXPAND_VIEWPORT_PADDING)),
y: Math.min(Math.max(position.y, EXPAND_VIEWPORT_PADDING), Math.max(EXPAND_VIEWPORT_PADDING, window.innerHeight - size.height - EXPAND_VIEWPORT_PADDING)),
};
}
function readExpandSize(): ExpandSize {
if (typeof window === "undefined") return { width: EXPAND_DEFAULT_WIDTH, height: EXPAND_DEFAULT_HEIGHT };
try {
const raw = window.localStorage.getItem(RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<ExpandSize>;
if (typeof parsed.width === "number" && typeof parsed.height === "number") {
return clampExpandSize({ width: parsed.width, height: parsed.height });
}
}
} catch {
// ignore corrupted persisted size
}
return clampExpandSize({ width: EXPAND_DEFAULT_WIDTH, height: EXPAND_DEFAULT_HEIGHT });
}
function writeExpandSize(size: ExpandSize): ExpandSize {
const clamped = clampExpandSize(size);
if (typeof window !== "undefined") {
window.localStorage.setItem(RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY, JSON.stringify(clamped));
}
return clamped;
}
function readExpandPosition(size: ExpandSize): ExpandPosition {
if (typeof window === "undefined") return { x: EXPAND_VIEWPORT_PADDING, y: EXPAND_VIEWPORT_PADDING };
try {
const raw = window.localStorage.getItem(RIGHT_DOCK_EXPAND_MODAL_POSITION_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<ExpandPosition>;
if (typeof parsed.x === "number" && typeof parsed.y === "number") {
return clampExpandPosition({ x: parsed.x, y: parsed.y }, size);
}
}
} catch {
// ignore corrupted persisted position
}
// Default: roughly centered.
return clampExpandPosition({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2 }, size);
}
function writeExpandPosition(position: ExpandPosition, size: ExpandSize): ExpandPosition {
const clamped = clampExpandPosition(position, size);
if (typeof window !== "undefined") {
window.localStorage.setItem(RIGHT_DOCK_EXPAND_MODAL_POSITION_STORAGE_KEY, JSON.stringify(clamped));
}
return clamped;
}
type ExpandResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const EXPAND_RESIZE_DIRECTIONS: ExpandResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
type RenderableOverflowViewEntry = OverflowViewEntry & Required<Pick<OverflowViewEntry, "render">>;
export interface RightDockExpandModalProps {
viewKey: OverflowViewKey | null;
renderProps: OverflowViewRenderProps;
visibilityOptions?: OverflowViewVisibilityOptions;
onClose: () => void;
returnFocusRef?: RefObject<HTMLElement | null>;
}
/*
FNXC:Navigation 2026-06-21-00:00:
Expanded right-dock views reuse the same overflow registry render function as the dock body, so expanding changes only available space and never swaps to a divergent component or prop contract.
FNXC:Navigation 2026-06-21-20:16:
FN-6882 makes most right-dock entries launcher actions. The expand modal is restricted to inline view entries so action-only tools cannot open an empty modal body.
FNXC:i18n 2026-06-22-00:00:
Expanded right-dock modal affordance labels are accessibility copy and must use the app namespace so locale catalogs and fallback tests cover the modal surface with the dock controls.
*/
export function RightDockExpandModal({
viewKey,
renderProps,
visibilityOptions = {},
onClose,
returnFocusRef,
}: RightDockExpandModalProps) {
const { t } = useTranslation("app");
const resolvedEntry = viewKey ? findOverflowViewEntry(viewKey, visibilityOptions) : undefined;
const entry: RenderableOverflowViewEntry | undefined = resolvedEntry?.render ? { ...resolvedEntry, render: resolvedEntry.render } : undefined;
const [size, setSizeState] = useState<ExpandSize>(() => readExpandSize());
const [position, setPositionState] = useState<ExpandPosition>(() => readExpandPosition(readExpandSize()));
// FNXC:FloatingWindow 2026-06-22-21:30: The right-dock pop-out shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type.
const [zIndex, setZIndex] = useState<number>(() => nextFloatingZ());
const bringToFront = useCallback(() => {
setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ()));
}, []);
/*
FNXC:RightDock 2026-06-22-17:40:
A single active-drag teardown lives here (drag OR resize). pointerup/pointercancel run it, and the unmount effect runs it too, so a drag interrupted by close/unmount never leaks document pointer listeners or a pending rAF — this was a P1 in review of the terminal floating window.
*/
const dragTeardownRef = useRef<(() => void) | null>(null);
const persistSize = useCallback((next: ExpandSize) => {
setSizeState(writeExpandSize(next));
}, []);
const persistPosition = useCallback((next: ExpandPosition, withSize: ExpandSize) => {
setPositionState(writeExpandPosition(next, withSize));
}, []);
const closeAndRestoreFocus = useCallback(() => {
onClose();
window.setTimeout(() => returnFocusRef?.current?.focus(), 0);
}, [onClose, returnFocusRef]);
/*
FNXC:RightDock 2026-06-22-17:40:
Header drag: pointerdown on the title bar moves the panel via state-driven `position: fixed; left/top`. Pointer capture keeps the drag alive past the header bounds, updates are rAF-batched so the move stays smooth, and the panel is clamped on-screen. Clicks on the close button are excluded so dragging never swallows the close.
FNXC:RightDock 2026-06-22-18:50:
Touch smoothness fix: listen for pointermove/up on the CAPTURED element (`captureTarget` = event.currentTarget) rather than `document`. `setPointerCapture` redirects every move for this pointerId to that element, so element-scoped listeners receive the full stream even when the finger drifts off the header — and they pair cleanly with `touch-action: none` (CSS) without a separate non-passive document listener. clientX/clientY are read from the captured pointer's move events. Raw moves are coalesced into a single rAF (`frame`) so we set left/top at most once per frame and never thrash layout on a flood of touch-move events.
*/
const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest("button")) return;
event.preventDefault();
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startPosition = position;
const currentSize = size;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
let latest = startPosition;
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY };
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setPositionState(clampExpandPosition(latest, currentSize));
});
};
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
persistPosition(latest, currentSize);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
}
// FNXC:RightDock 2026-06-22-17:40: Close/unmount-mid-drag teardown cancels the rAF and drops the listeners without persisting a partial move.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
};
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [persistPosition, position, size]);
/*
FNXC:RightDock 2026-06-22-17:40:
Corner/edge resize: pointer events resize the panel, rAF-batched for smoothness. West/north handles also shift the panel origin so the opposite edge stays pinned. Same teardown discipline as the drag.
*/
const handleFloatingResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>, direction: ExpandResizeDirection) => {
event.preventDefault();
event.stopPropagation();
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startSize = size;
const startPosition = position;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
let latestSize = startSize;
let latestPosition = startPosition;
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const nextSize = clampExpandSize({
width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0),
height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0),
});
const nextPosition = {
x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0),
y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0),
};
latestSize = nextSize;
latestPosition = nextPosition;
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setSizeState(latestSize);
setPositionState(clampExpandPosition(latestPosition, latestSize));
});
};
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
persistSize(latestSize);
persistPosition(latestPosition, latestSize);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
}
// FNXC:RightDock 2026-06-22-17:40: Close/unmount-mid-resize teardown.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
};
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [persistPosition, persistSize, position, size]);
// FNXC:RightDock 2026-06-22-17:40: Run any active drag/resize teardown on unmount so document pointer listeners + a pending rAF never outlive the modal.
useEffect(() => () => dragTeardownRef.current?.(), []);
useEffect(() => {
if (entry) return undefined;
return () => {
returnFocusRef?.current?.focus();
};
}, [entry, returnFocusRef]);
if (!entry) {
return null;
}
const Icon = entry.icon;
const expandedViewLabel = t("rightDock.viewExpanded", "{{label}} expanded", { label: entry.label });
const panelStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex,
} as CSSProperties;
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so this floating modal shares the ONE root stacking context with the other floating modals (FloatingWindow/terminal/New Task) — the shared 10100+ z stack only orders correctly across types when they all live at the document root.
return createPortal(
<div className="modal-overlay open right-dock-expand-modal-overlay" role="dialog" aria-modal="false" aria-label={expandedViewLabel} data-testid="right-dock-expand-modal" style={{ zIndex }}>
<div
className="modal right-dock-expand-modal right-dock-expand-modal--floating"
style={panelStyle}
onPointerDownCapture={bringToFront}
onFocusCapture={bringToFront}
>
{EXPAND_RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`right-dock-expand-resize-handle right-dock-expand-resize-handle--${direction}`}
data-testid={`right-dock-expand-resize-${direction}`}
role="separator"
aria-label={t("rightDock.resizeExpandedView", "Resize expanded right dock window")}
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
/>
))}
<div
className="modal-header right-dock-expand-modal__header right-dock-expand-modal__header--draggable"
data-testid="right-dock-expand-drag-handle"
onPointerDown={handleFloatingDragPointerDown}
>
<div className="right-dock-expand-modal__title">
<Maximize2 size={16} />
<Icon size={16} />
<span>{entry.label}</span>
</div>
<button className="modal-close" onClick={closeAndRestoreFocus} aria-label={t("rightDock.closeExpandedView", "Close expanded right dock view")} data-testid="right-dock-expand-close">
<X size={20} />
</button>
</div>
{/*
FNXC:RightDockFiles 2026-06-22-15:00:
Tag the render props with `surface="expand"` so registry entries (notably Files) deterministically choose their pop-out layout instead of guessing from a measured container width. DockFilesView reads this to force its LEFT|RIGHT two-pane layout.
*/}
<div className="right-dock-expand-modal__body" data-testid="right-dock-expand-body">
{entry.render({ ...renderProps, surface: "expand" })}
</div>
</div>
</div>,
document.body,
);
}