Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
154 lines
11 KiB
TypeScript
154 lines
11 KiB
TypeScript
import { useId, useRef, useState, type DragEvent, type KeyboardEvent } from "react";
|
|
import { ArrowDown, ArrowUp, GripVertical, LockKeyhole, Plus, Trash2 } from "lucide-react";
|
|
import type { NavigationPreferences, PlatformNavItem, ProductAreaContribution } from "../types";
|
|
import ActionToolbar from "./ActionToolbar";
|
|
import Button from "./Button";
|
|
import IconButton from "./IconButton";
|
|
import ToggleSwitch from "./ToggleSwitch";
|
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
|
import { navigationEditorTranslations } from "../i18n/navigationEditorTranslations";
|
|
import { inheritedNavigationLayout, materializeNavigationLayout, moveNavigationEntry, navigationEditorOrder, navigationId, type NavigationPreferenceScope } from "./navigationPreferenceLayout";
|
|
|
|
export default function NavigationPreferenceEditor({ items, productAreas = [], value, onChange, scope, disabled = false }: {
|
|
items: PlatformNavItem[];
|
|
productAreas?: ProductAreaContribution[];
|
|
value: NavigationPreferences | null;
|
|
onChange: (value: NavigationPreferences | null) => void;
|
|
scope: NavigationPreferenceScope;
|
|
disabled?: boolean;
|
|
}) {
|
|
const { translateText, language, t } = usePlatformLanguage();
|
|
function navigationText(name: string, values: Record<string, string | number> = {}) {
|
|
const fallback = navigationEditorTranslations[language]?.[name] ?? navigationEditorTranslations.en[name] ?? name;
|
|
const template = t(`i18n:govoplan-core.navigation_editor_${name}`, fallback);
|
|
return template.replace(/\{(\w+)\}/g, (match, field: string) => String(values[field] ?? match));
|
|
}
|
|
const instructionsId = useId();
|
|
const inherited = inheritedNavigationLayout(items, scope, productAreas);
|
|
const editable = materializeNavigationLayout(value, inherited);
|
|
const layoutStatus = value === null ? "inherited" : (editable.separators?.length ?? 0) > 0 ? "grouped" : "flat";
|
|
const byId = new Map(items.map((item) => [navigationId(item), item]));
|
|
const inheritedScope = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant";
|
|
const ancestorLocked = (id: string) => Boolean(byId.get(id)?.navigationLayers?.[inheritedScope]?.locked);
|
|
const locked = (id: string) => ancestorLocked(id) || Boolean(editable.locked?.includes(id));
|
|
const separators = new Map((editable.separators ?? []).map((item) => [item.id, item]));
|
|
const effective = { ...editable, hidden: editable.hidden.filter((id) => !locked(id)) };
|
|
const orderedIds = navigationEditorOrder(items, effective);
|
|
const available = items.filter((item) => effective.hidden.includes(navigationId(item)));
|
|
const [selectedModule, setSelectedModule] = useState("");
|
|
const [dragged, setDragged] = useState<string | null>(null);
|
|
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
|
const [announcement, setAnnouncement] = useState("");
|
|
const pickup = useRef<{ id: string; original: NavigationPreferences | null } | null>(null);
|
|
const addId = available.some((item) => navigationId(item) === selectedModule) ? selectedModule : navigationId(available[0] ?? { to: "", label: "" });
|
|
|
|
function labelFor(id: string) {
|
|
return translateText(byId.get(id)?.label ?? separators.get(id)?.label ?? "") || translateText(navigationText("separator"));
|
|
}
|
|
|
|
function update(patch: Partial<NavigationPreferences>) {
|
|
if (disabled) return;
|
|
const next = { ...effective, order: orderedIds, ...patch, contract_version: "1" as const };
|
|
// Optional modules can be temporarily absent. Preserve their stored place
|
|
// instead of destroying it when an unrelated visible entry is edited.
|
|
const unavailable = new Set(editable.order.filter((id) => !byId.has(id) && !separators.has(id)));
|
|
const order = [...next.order];
|
|
for (const id of editable.order) {
|
|
if (!unavailable.has(id) || order.includes(id)) continue;
|
|
const following = editable.order.slice(editable.order.indexOf(id) + 1).find((entry) => order.includes(entry));
|
|
order.splice(following ? order.indexOf(following) : order.length, 0, id);
|
|
}
|
|
onChange({ ...next, order });
|
|
}
|
|
|
|
function move(id: string, offset: -1 | 1) {
|
|
const target = orderedIds[orderedIds.indexOf(id) + offset];
|
|
if (!target || disabled) return;
|
|
const next = moveNavigationEntry(orderedIds, id, target, offset === 1);
|
|
update({ order: next });
|
|
setAnnouncement(translateText(navigationText("moved", { label: labelFor(id), position: next.indexOf(id) + 1, total: next.length })));
|
|
}
|
|
|
|
function remove(id: string) {
|
|
if (locked(id)) return;
|
|
update({ order: orderedIds.filter((item) => item !== id),
|
|
hidden: byId.has(id) ? [...new Set([...effective.hidden, id])] : effective.hidden,
|
|
separators: (effective.separators ?? []).filter((item) => item.id !== id) });
|
|
}
|
|
|
|
function drop(event: DragEvent, target: string) {
|
|
event.preventDefault();
|
|
if (disabled || !dragged) return;
|
|
const bounds = event.currentTarget.getBoundingClientRect();
|
|
const next = moveNavigationEntry(orderedIds, dragged, target, event.clientY > bounds.top + bounds.height / 2);
|
|
if (next.some((id, index) => id !== orderedIds[index])) update({ order: next });
|
|
setAnnouncement(translateText(navigationText("moved", { label: labelFor(dragged), position: next.indexOf(dragged) + 1, total: next.length })));
|
|
setDragged(null); setDropTarget(null);
|
|
}
|
|
|
|
function keyboardDrag(event: KeyboardEvent, id: string) {
|
|
if (disabled) return;
|
|
if (event.key === " " || event.key === "Enter") {
|
|
event.preventDefault();
|
|
if (pickup.current) { pickup.current = null; setDragged(null); setAnnouncement(translateText(navigationText("dropped"))); }
|
|
else { pickup.current = { id, original: value }; setDragged(id); setAnnouncement(translateText(navigationText("picked_up"))); }
|
|
} else if (pickup.current?.id === id && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
|
|
event.preventDefault(); move(id, event.key === "ArrowUp" ? -1 : 1);
|
|
} else if (pickup.current && event.key === "Escape") {
|
|
event.preventDefault(); onChange(pickup.current.original); pickup.current = null; setDragged(null); setAnnouncement(translateText(navigationText("cancelled")));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="navigation-preference-editor" data-navigation-preference-scope={scope}>
|
|
<ActionToolbar className="navigation-preference-toolbar" justify="between">
|
|
<p className="muted small-note" id={instructionsId}>{navigationText("help")}</p>
|
|
<Button onClick={() => onChange(null)} disabled={disabled || value === null}>{navigationText("inherit")}</Button>
|
|
</ActionToolbar>
|
|
<p className="muted small-note" data-navigation-layout-status={layoutStatus}>
|
|
{navigationText(`${layoutStatus}_status`)}
|
|
</p>
|
|
<ActionToolbar className="navigation-preference-add">
|
|
<select aria-label={translateText(navigationText("available"))} value={addId} disabled={disabled || available.length === 0} onChange={(event) => setSelectedModule(event.target.value)}>
|
|
{available.length === 0 && <option value="">{translateText(navigationText("all_added"))}</option>}
|
|
{available.map((item) => <option key={navigationId(item)} value={navigationId(item)}>{translateText(item.label)}</option>)}
|
|
</select>
|
|
<Button disabled={disabled || !addId} onClick={() => update({ order: [...orderedIds, addId], hidden: effective.hidden.filter((id) => id !== addId) })}><Plus size={16} aria-hidden="true" />{navigationText("add_module")}</Button>
|
|
<Button disabled={disabled || (effective.separators?.length ?? 0) >= 128} onClick={() => {
|
|
const separator = { id: `separator:${crypto.randomUUID()}`, label: "" };
|
|
update({ order: [...orderedIds, separator.id], separators: [...(effective.separators ?? []), separator] });
|
|
}}><Plus size={16} aria-hidden="true" />{navigationText("add_separator")}</Button>
|
|
</ActionToolbar>
|
|
<ol className="navigation-preference-list" aria-label={translateText(navigationText("layout"))}>
|
|
{orderedIds.map((id, index) => {
|
|
const item = byId.get(id);
|
|
const separator = separators.get(id);
|
|
const label = labelFor(id);
|
|
return (
|
|
<li key={id} data-navigation-id={id} data-navigation-kind={separator ? "separator" : "module"} data-navigation-locked={locked(id)} data-dragging={dragged === id} data-drop-target={dropTarget === id}
|
|
onDragOver={(event) => { if (!disabled && dragged) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; setDropTarget(id); } }} onDrop={(event) => drop(event, id)}>
|
|
<div className="navigation-preference-order-actions">
|
|
<IconButton label={navigationText("reorder", { label })} icon={<GripVertical size={16} />} className="navigation-preference-drag" disabled={disabled} draggable={!disabled}
|
|
aria-describedby={instructionsId} aria-pressed={dragged === id} onKeyDown={(event) => keyboardDrag(event, id)}
|
|
onDragStart={(event) => { setDragged(id); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", id); }} onDragEnd={() => { setDragged(null); setDropTarget(null); }} />
|
|
<IconButton label={navigationText("up", { label })} icon={<ArrowUp size={16} />} onClick={() => move(id, -1)} disabled={disabled || index === 0} />
|
|
<IconButton label={navigationText("down", { label })} icon={<ArrowDown size={16} />} onClick={() => move(id, 1)} disabled={disabled || index === orderedIds.length - 1} />
|
|
</div>
|
|
<div className="navigation-preference-label">
|
|
{separator ? <label><span>{navigationText("separator_label")}</span><input value={translateText(separator.label)} maxLength={120} disabled={disabled} placeholder={translateText(navigationText("separator"))} onChange={(event) => update({ separators: (effective.separators ?? []).map((entry) => entry.id === id ? { ...entry, label: event.target.value } : entry) })} /></label> : <><strong>{label}</strong><span>{id}</span></>}
|
|
</div>
|
|
<div className="navigation-preference-item-actions">
|
|
{item && (scope === "system" || scope === "tenant") && <ToggleSwitch label={<><LockKeyhole size={14} aria-hidden="true" />{navigationText("locked")}</>} checked={locked(id)} disabled={disabled || ancestorLocked(id)} onChange={(next) => update({ locked: next ? [...new Set([...(effective.locked ?? []), id])] : (effective.locked ?? []).filter((entry) => entry !== id), hidden: effective.hidden.filter((entry) => entry !== id) })} />}
|
|
{item && locked(id) && <span className="muted small-note" title={translateText(navigationText("locked_help"))}><LockKeyhole size={14} aria-label={translateText(navigationText("locked"))} /></span>}
|
|
<IconButton label={navigationText("remove", { label })} icon={<Trash2 size={16} />} disabled={disabled || locked(id)} disabledReason={locked(id) ? navigationText("locked_help") : undefined} onClick={() => remove(id)} />
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
{orderedIds.length === 0 && <p className="muted">{navigationText("empty")}</p>}
|
|
<p className="visually-hidden" role="status" aria-live="polite">{announcement}</p>
|
|
</div>
|
|
);
|
|
}
|