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
285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
import { Maximize2 } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
findOverflowViewEntry,
|
|
getVisibleOverflowViewEntries,
|
|
isOverflowViewKeyVisible,
|
|
type OverflowViewKey,
|
|
type OverflowViewRenderProps,
|
|
type OverflowViewVisibilityOptions,
|
|
} from "./overflowViewRegistry";
|
|
import "./RightDock.css";
|
|
|
|
export const RIGHT_DOCK_DEFAULT_WIDTH = 360;
|
|
export const RIGHT_DOCK_MIN_WIDTH = 280;
|
|
/*
|
|
FNXC:RightDock 2026-06-23-00:50:
|
|
The right-dock resize cap was raised 720 -> 1280 so the user can drag the dock MUCH wider (e.g. to run the Files view as a true two-pane tree|viewer split). The clamp and the persisted-width read both funnel through clampRightDockWidth/RIGHT_DOCK_MAX_WIDTH, so a single constant governs the drag clamp, the keyboard-step clamp, the stored-width read, and the resize-handle aria-valuemax. The CSS still wraps the rendered width in min(100%, ...), so the dock can never exceed the viewport even at the larger cap.
|
|
*/
|
|
export const RIGHT_DOCK_MAX_WIDTH = 1280;
|
|
export const RIGHT_DOCK_WIDTH_STORAGE_KEY = "fusion:right-dock-width";
|
|
export const RIGHT_DOCK_VIEW_STORAGE_KEY = "fusion:right-dock-view";
|
|
export const RIGHT_DOCK_OPEN_STORAGE_KEY = "fusion:right-dock-open";
|
|
|
|
function clampRightDockWidth(width: number): number {
|
|
return Math.max(RIGHT_DOCK_MIN_WIDTH, Math.min(RIGHT_DOCK_MAX_WIDTH, width));
|
|
}
|
|
|
|
export function readStoredRightDockWidth(): number {
|
|
if (typeof window === "undefined") return RIGHT_DOCK_DEFAULT_WIDTH;
|
|
const stored = window.localStorage.getItem(RIGHT_DOCK_WIDTH_STORAGE_KEY);
|
|
const parsed = stored ? Number(stored) : NaN;
|
|
return Number.isFinite(parsed) ? clampRightDockWidth(parsed) : RIGHT_DOCK_DEFAULT_WIDTH;
|
|
}
|
|
|
|
export function readStoredRightDockOpen(): boolean {
|
|
if (typeof window === "undefined") return true;
|
|
return window.localStorage.getItem(RIGHT_DOCK_OPEN_STORAGE_KEY) !== "false";
|
|
}
|
|
|
|
export function persistRightDockOpen(open: boolean): void {
|
|
try {
|
|
window.localStorage.setItem(RIGHT_DOCK_OPEN_STORAGE_KEY, String(open));
|
|
} catch {
|
|
// Ignore storage errors.
|
|
}
|
|
}
|
|
|
|
function isInlineOverflowViewKey(key: string, options: OverflowViewVisibilityOptions): key is OverflowViewKey {
|
|
const entry = findOverflowViewEntry(key as OverflowViewKey, options);
|
|
return Boolean(entry?.render);
|
|
}
|
|
|
|
function readStoredRightDockView(options: OverflowViewVisibilityOptions): OverflowViewKey {
|
|
if (typeof window === "undefined") return "files";
|
|
const stored = window.localStorage.getItem(RIGHT_DOCK_VIEW_STORAGE_KEY);
|
|
return stored && isOverflowViewKeyVisible(stored, options) && isInlineOverflowViewKey(stored, options) ? stored : "files";
|
|
}
|
|
|
|
function persistRightDockWidth(width: number): void {
|
|
try {
|
|
window.localStorage.setItem(RIGHT_DOCK_WIDTH_STORAGE_KEY, String(width));
|
|
} catch {
|
|
// Ignore storage errors.
|
|
}
|
|
}
|
|
|
|
function persistRightDockView(key: OverflowViewKey): void {
|
|
try {
|
|
window.localStorage.setItem(RIGHT_DOCK_VIEW_STORAGE_KEY, key);
|
|
} catch {
|
|
// Ignore storage errors.
|
|
}
|
|
}
|
|
|
|
export interface RightDockProps {
|
|
open: boolean;
|
|
renderProps: OverflowViewRenderProps;
|
|
visibilityOptions?: OverflowViewVisibilityOptions;
|
|
onExpand?: (key: OverflowViewKey) => void;
|
|
footerVisible?: boolean;
|
|
}
|
|
|
|
/*
|
|
FNXC:Navigation 2026-06-21-00:00:
|
|
The right dock is an auxiliary tablet/desktop surface: it remembers the last overflow destination, starts on Files when none is valid, and resizes from its left edge without changing the canonical Header/MobileNavBar active navigation state.
|
|
|
|
FNXC:Navigation 2026-06-21-20:14:
|
|
FN-6882 splits right-dock entries into launcher actions and inline views. Action tabs invoke their existing Header handlers without replacing the Files body; only inline entries persist selection or expand into the modal.
|
|
|
|
FNXC:Navigation 2026-06-22-09:00:
|
|
The right dock is visible by default on tablet/desktop project screens. Show/hide is owned solely by the canonical Header right-sidebar toggle (the in-dock collapse toggle was removed); the dock takes only `open` and renders null when closed so the main content reclaims the space.
|
|
|
|
FNXC:i18n 2026-06-22-00:00:
|
|
Right-dock affordance labels are user-facing accessibility copy, so route them through the app namespace and keep English defaults colocated with the component for tests and fallback rendering.
|
|
*/
|
|
export function RightDock({
|
|
open,
|
|
renderProps,
|
|
visibilityOptions = {},
|
|
onExpand,
|
|
footerVisible = false,
|
|
}: RightDockProps) {
|
|
const { t } = useTranslation("app");
|
|
const entries = useMemo(() => getVisibleOverflowViewEntries(visibilityOptions), [visibilityOptions]);
|
|
const [selectedKey, setSelectedKey] = useState<OverflowViewKey>(() => readStoredRightDockView(visibilityOptions));
|
|
const [width, setWidth] = useState(readStoredRightDockWidth);
|
|
/*
|
|
FNXC:Navigation 2026-06-22-09:00:
|
|
The dock renders null while closed, so a resize drag that is still mid-flight when the dock closes (or the component unmounts) would leave document pointer listeners and a frozen body.userSelect behind. Store the active drag teardown in a ref and run it from an unmount-cleanup effect to plug that leak.
|
|
*/
|
|
const resizeTeardownRef = useRef<(() => void) | null>(null);
|
|
useEffect(() => () => resizeTeardownRef.current?.(), []);
|
|
|
|
useEffect(() => {
|
|
if (!isOverflowViewKeyVisible(selectedKey, visibilityOptions) || !isInlineOverflowViewKey(selectedKey, visibilityOptions)) {
|
|
setSelectedKey("files");
|
|
persistRightDockView("files");
|
|
}
|
|
}, [selectedKey, visibilityOptions]);
|
|
|
|
const selectedEntry = (findOverflowViewEntry(selectedKey, visibilityOptions)?.render
|
|
? findOverflowViewEntry(selectedKey, visibilityOptions)
|
|
: findOverflowViewEntry("files", visibilityOptions)) ?? entries.find((entry) => entry.render);
|
|
|
|
const selectEntry = useCallback((key: OverflowViewKey) => {
|
|
const entry = findOverflowViewEntry(key, visibilityOptions);
|
|
if (entry?.onActivate) {
|
|
entry.onActivate(renderProps);
|
|
return;
|
|
}
|
|
if (!entry?.render) return;
|
|
setSelectedKey(key);
|
|
persistRightDockView(key);
|
|
}, [renderProps, visibilityOptions]);
|
|
|
|
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const resizeHandle = event.currentTarget;
|
|
if (typeof resizeHandle.setPointerCapture === "function") {
|
|
resizeHandle.setPointerCapture(event.pointerId);
|
|
}
|
|
|
|
const startX = event.clientX;
|
|
const startWidth = width;
|
|
let latestWidth = startWidth;
|
|
const previousUserSelect = document.body.style.userSelect;
|
|
document.body.style.userSelect = "none";
|
|
|
|
const onPointerMove = (moveEvent: PointerEvent) => {
|
|
const nextWidth = clampRightDockWidth(startWidth + startX - moveEvent.clientX);
|
|
latestWidth = nextWidth;
|
|
setWidth(nextWidth);
|
|
};
|
|
|
|
/*
|
|
FNXC:Navigation 2026-06-22-09:00:
|
|
teardown restores body.userSelect, drops the document pointermove/up/cancel listeners, and persists the final width. It runs on pointerup, pointercancel (touch/pen interruption), and on unmount/dock-close via resizeTeardownRef so listeners never leak.
|
|
*/
|
|
const teardown = (upEvent?: PointerEvent) => {
|
|
if (upEvent && typeof resizeHandle.releasePointerCapture === "function") {
|
|
resizeHandle.releasePointerCapture(upEvent.pointerId);
|
|
}
|
|
document.body.style.userSelect = previousUserSelect;
|
|
document.removeEventListener("pointermove", onPointerMove);
|
|
document.removeEventListener("pointerup", onPointerUp);
|
|
document.removeEventListener("pointercancel", onPointerUp);
|
|
resizeTeardownRef.current = null;
|
|
persistRightDockWidth(latestWidth);
|
|
};
|
|
|
|
const onPointerUp = (upEvent: PointerEvent) => teardown(upEvent);
|
|
|
|
resizeTeardownRef.current = () => teardown();
|
|
document.addEventListener("pointermove", onPointerMove);
|
|
document.addEventListener("pointerup", onPointerUp);
|
|
document.addEventListener("pointercancel", onPointerUp);
|
|
}, [width]);
|
|
|
|
const handleResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
|
event.preventDefault();
|
|
const step = event.shiftKey ? 48 : 16;
|
|
const delta = event.key === "ArrowLeft" ? step : -step;
|
|
const nextWidth = clampRightDockWidth(width + delta);
|
|
setWidth(nextWidth);
|
|
persistRightDockWidth(nextWidth);
|
|
}, [width]);
|
|
|
|
if (!selectedEntry) {
|
|
return null;
|
|
}
|
|
|
|
/*
|
|
FNXC:Navigation 2026-06-22-00:00:
|
|
The right dock is no longer a persistent rail: when closed it renders nothing so the main content reclaims the space (the shell is flex, so a null dock simply reflows). The Header right-sidebar toggle is the canonical show/hide control.
|
|
*/
|
|
if (!open) {
|
|
return null;
|
|
}
|
|
|
|
const SelectedIcon = selectedEntry.icon;
|
|
const dockWidth = `${width}px`;
|
|
const expandSelectedViewLabel = t("rightDock.expandView", "Expand {{label}}", { label: selectedEntry.label });
|
|
|
|
return (
|
|
<aside
|
|
className={`right-dock${open ? "" : " right-dock--collapsed"}${footerVisible ? " right-dock--with-footer" : ""}`}
|
|
style={dockWidth ? { width: dockWidth } : undefined}
|
|
aria-label={t("rightDock.label", "Right dock")}
|
|
data-testid="right-dock"
|
|
>
|
|
{open ? (
|
|
<div
|
|
className="right-dock__resize-handle"
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-valuemin={RIGHT_DOCK_MIN_WIDTH}
|
|
aria-valuemax={RIGHT_DOCK_MAX_WIDTH}
|
|
aria-valuenow={width}
|
|
aria-label={t("rightDock.resize", "Resize right dock")}
|
|
tabIndex={0}
|
|
data-testid="right-dock-resize-handle"
|
|
onPointerDown={handleResizeStart}
|
|
onKeyDown={handleResizeKeyDown}
|
|
/>
|
|
) : null}
|
|
<div className="right-dock__toolbar">
|
|
<div className="right-dock__tabs" role="tablist" aria-label={t("rightDock.views", "Right dock views")}>
|
|
{entries.map((entry) => {
|
|
const Icon = entry.icon;
|
|
const selected = Boolean(entry.render && entry.key === selectedEntry.key);
|
|
return (
|
|
<button
|
|
key={entry.key}
|
|
type="button"
|
|
className={`btn-icon right-dock__tab${selected ? " right-dock__tab--active" : ""}`}
|
|
aria-label={entry.label}
|
|
title={entry.label}
|
|
aria-selected={selected}
|
|
role="tab"
|
|
data-testid={entry.testId}
|
|
onClick={() => selectEntry(entry.key)}
|
|
>
|
|
<Icon size={16} />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="right-dock__actions">
|
|
{open && selectedEntry.render ? (
|
|
<button
|
|
type="button"
|
|
className="btn-icon right-dock__expand"
|
|
aria-label={expandSelectedViewLabel}
|
|
title={expandSelectedViewLabel}
|
|
data-testid="right-dock-expand"
|
|
onClick={() => onExpand?.(selectedEntry.key)}
|
|
>
|
|
<Maximize2 size={16} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{open ? (
|
|
<>
|
|
<div className="right-dock__header">
|
|
<SelectedIcon size={16} />
|
|
<div className="right-dock__title" role="heading" aria-level={3}>{selectedEntry.label}</div>
|
|
</div>
|
|
<div className="right-dock__body" role="tabpanel" aria-label={selectedEntry.label} data-testid="right-dock-body">
|
|
{/*
|
|
FNXC:RightDockFiles 2026-06-23-00:50:
|
|
Thread the live dock width down to registry render functions as `dockWidth` (alongside surface="dock") so a view can deterministically choose its wide layout from the actual dock size. The Files entry uses this to force two-pane when the dock is wide enough, sidestepping the @container query that never reliably fired in the narrow-vs-wide dock body.
|
|
*/}
|
|
{selectedEntry.render?.({ ...renderProps, surface: "dock", dockWidth: width })}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</aside>
|
|
);
|
|
}
|