feat: consolidate shared UI and harden browser authority for release

This commit is contained in:
2026-09-08 01:35:05 +02:00
parent ac40774785
commit b75ca34295
143 changed files with 8664 additions and 752 deletions
@@ -1,139 +1,149 @@
import { ArrowDown, ArrowUp, LockKeyhole } from "lucide-react";
import type { NavigationPreferences, PlatformNavItem } from "../types";
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";
type Scope = "system" | "tenant" | "user";
export default function NavigationPreferenceEditor({
items,
value,
onChange,
scope,
disabled = false
}: {
export default function NavigationPreferenceEditor({ items, productAreas = [], value, onChange, scope, disabled = false }: {
items: PlatformNavItem[];
productAreas?: ProductAreaContribution[];
value: NavigationPreferences | null;
onChange: (value: NavigationPreferences | null) => void;
scope: Scope;
scope: NavigationPreferenceScope;
disabled?: boolean;
}) {
const { translateText } = usePlatformLanguage();
const inheritedScope = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant";
const inherited = preferenceFromLayer(items, inheritedScope);
const editable = value ?? inherited;
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 byId = new Map(items.map((item) => [navigationId(item), item]));
const inheritedIds = [...items]
.sort((left, right) => layerOrder(left, inheritedScope) - layerOrder(right, inheritedScope))
.map(navigationId);
const orderedIds = [
...editable.order.filter((id) => byId.has(id)),
...inheritedIds.filter((id) => !editable.order.includes(id))
];
const hidden = new Set(editable.hidden);
const localLocks = new Set(editable.locked ?? []);
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>) {
onChange({ ...editable, ...patch, contract_version: "1" });
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 index = orderedIds.indexOf(id);
const target = index + offset;
if (index < 0 || target < 0 || target >= orderedIds.length) return;
const next = [...orderedIds];
[next[index], next[target]] = [next[target], next[index]];
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 setVisible(id: string, visible: boolean) {
const next = new Set(hidden);
if (visible) next.delete(id);
else next.add(id);
update({ order: orderedIds, hidden: [...next] });
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 setLocked(id: string, locked: boolean) {
const next = new Set(localLocks);
if (locked) next.add(id);
else next.delete(id);
const nextHidden = new Set(hidden);
if (locked) nextHidden.delete(id);
update({ order: orderedIds, hidden: [...nextHidden], locked: [...next] });
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">
Higher personal settings take precedence over tenant and system order. Locked entries remain visible.
</p>
<Button onClick={() => onChange(null)} disabled={disabled || value === null}>
Use inherited order
</Button>
<p className="muted small-note" id={instructionsId}>{navigationText("help")}</p>
<Button onClick={() => onChange(null)} disabled={disabled || value === null}>{navigationText("inherit")}</Button>
</ActionToolbar>
<ol className="navigation-preference-list">
<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);
if (!item) return null;
const inheritedState = item.navigationLayers?.[inheritedScope];
const ancestorLocked = Boolean(inheritedState?.locked);
const locked = ancestorLocked || localLocks.has(id);
const label = translateText(item.label);
const separator = separators.get(id);
const label = labelFor(id);
return (
<li key={id} data-navigation-id={id} data-navigation-locked={locked ? "true" : "false"}>
<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={`Move ${label} up`} icon={<ArrowUp size={16} />} onClick={() => move(id, -1)} disabled={disabled || index === 0} />
<IconButton label={`Move ${label} down`} icon={<ArrowDown size={16} />} onClick={() => move(id, 1)} disabled={disabled || index === orderedIds.length - 1} />
<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">
<strong>{label}</strong>
<span>{id}</span>
{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>
<ToggleSwitch
label="Visible"
checked={locked || !hidden.has(id)}
disabled={disabled || locked}
help={locked ? `Locked by ${inheritedState?.lock_source ?? scope}` : undefined}
onChange={(visible) => setVisible(id, visible)}
/>
{scope !== "user" && (
<ToggleSwitch
label={<><LockKeyhole size={14} aria-hidden="true" /> Locked</>}
checked={locked}
disabled={disabled || ancestorLocked}
help={ancestorLocked ? `Locked by ${inheritedState?.lock_source}` : "Lower scopes cannot hide this entry."}
onChange={(next) => setLocked(id, next)}
/>
)}
</li>
);
})}
</ol>
{orderedIds.length === 0 && <p className="muted">{navigationText("empty")}</p>}
<p className="visually-hidden" role="status" aria-live="polite">{announcement}</p>
</div>
);
}
function navigationId(item: PlatformNavItem): string {
return item.navigationId ?? item.surfaceId ?? item.to;
}
function preferenceFromLayer(
items: PlatformNavItem[],
layer: "module" | "system" | "tenant"
): NavigationPreferences {
const ordered = [...items].sort((left, right) => layerOrder(left, layer) - layerOrder(right, layer));
return {
contract_version: "1",
order: ordered.map(navigationId),
hidden: ordered.filter((item) => item.navigationLayers?.[layer]?.visible === false).map(navigationId),
locked: []
};
}
function layerOrder(item: PlatformNavItem, layer: "module" | "system" | "tenant"): number {
return item.navigationLayers?.[layer]?.order ?? item.order ?? 100;
}