refactor: move view selection to titlebar menu

This commit is contained in:
2026-08-05 00:03:33 +02:00
parent e3c9c0dcef
commit efa59173e4
6 changed files with 125 additions and 51 deletions
@@ -24,6 +24,8 @@ assert(admin.includes("LOCKOUT_SURFACES") && admin.includes("requiredMissing"),
assert(patterns.includes('topicId: "views.interface-projections"') && patterns.includes('topicId: "views.reference.fields-and-consequences"'), "Views uses stable manifest-backed help references");
assert(moduleSource.includes("generatedTranslations") && moduleSource.includes('label: "i18n:govoplan-views.views"'), "Module and surface labels are localized");
assert(selector.includes("usePlatformLanguage") && selector.includes("required_by_administrator"), "The global selector localizes and explains its locked state");
assert(selector.includes("View as ViewIcon") && selector.includes("is-context-active"), "The global selector uses the compact View icon and marks an effective projection");
assert(selector.includes("useUnsavedChanges") && selector.includes("requestNavigation"), "View changes retain the shared dirty-navigation guard");
assert(translations.includes('"i18n:govoplan-views.views_administration_is_read_only"'), "Availability explanations are present in the translation catalogue");
console.log("Views surfaces satisfy the recorded interface pattern-language contract.");
+85 -22
View File
@@ -1,6 +1,7 @@
import { Eye, LockKeyhole } from "lucide-react";
import { useState } from "react";
import { Check, LockKeyhole, View as ViewIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
DismissibleAlert,
dispatchPlatformViewChanged,
usePlatformLanguage,
useUnsavedChanges,
@@ -15,10 +16,21 @@ export default function ViewSelector({
}: ViewSelectorProps) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { translateText } = usePlatformLanguage();
const { requestNavigation } = useUnsavedChanges();
const options = projection?.availableViews ?? [];
useEffect(() => {
function onPointerDown(event: MouseEvent) {
const target = event.target as Node;
if (menuRef.current && !menuRef.current.contains(target)) setOpen(false);
}
window.addEventListener("mousedown", onPointerDown);
return () => window.removeEventListener("mousedown", onPointerDown);
}, []);
if (!projection?.activeViewId && options.length === 0) return null;
async function performSelect(viewId: string) {
@@ -27,6 +39,7 @@ export default function ViewSelector({
try {
await selectEffectiveView(settings, viewId || null);
dispatchPlatformViewChanged();
setOpen(false);
} catch (caught) {
setError(
caught instanceof Error
@@ -45,6 +58,8 @@ export default function ViewSelector({
}
const locked = Boolean(projection?.locked);
const activeViewId = projection?.activeViewId ?? "";
const specialized = projection?.projectionActive ?? Boolean(activeViewId);
const diagnostic = projection?.diagnostics.find((item) => item.severity === "error")
?? projection?.diagnostics[0];
const title = translateText(error || diagnostic?.message || (
@@ -54,27 +69,75 @@ export default function ViewSelector({
));
return (
<label className="titlebar-view-selector" title={title}>
{locked
? <LockKeyhole size={16} aria-hidden="true" />
: <Eye size={16} aria-hidden="true" />}
<span className="sr-only">i18n:govoplan-views.current_view</span>
<select
value={projection?.activeViewId ?? ""}
disabled={busy || locked}
<div className="context-menu-wrap titlebar-view-menu-wrap" ref={menuRef}>
<button
type="button"
className={`titlebar-icon-link${specialized ? " is-context-active" : ""}`}
data-help-context-id="views.selector"
data-help-module-id="views"
data-help-scope="action"
title={title}
aria-label={translateText("i18n:govoplan-views.current_view")}
aria-invalid={Boolean(error) || undefined}
onChange={(event) => select(event.target.value)}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
{!locked && (
<option value="">
{translateText("i18n:govoplan-views.full_interface")}
</option>
)}
{options.map((option) => (
<option key={option.id} value={option.id}>{option.name}</option>
))}
</select>
</label>
<ViewIcon size={18} aria-hidden="true" />
</button>
{open && (
<div className="dropdown-menu titlebar-view-menu" role="menu">
<div className="titlebar-view-menu-heading">
<strong>i18n:govoplan-views.view_selector</strong>
{locked && <LockKeyhole size={15} aria-hidden="true" />}
</div>
{!locked && (
<button
type="button"
className={`dropdown-item${specialized ? "" : " active"}`}
disabled={busy}
onClick={() => select("")}
role="menuitemradio"
aria-checked={!specialized}
>
<span>i18n:govoplan-views.full_interface</span>
{!specialized && <Check size={16} aria-hidden="true" />}
</button>
)}
{options.map((option) => {
const active = option.id === activeViewId;
return (
<button
type="button"
key={option.id}
className={`dropdown-item${active ? " active" : ""}`}
disabled={busy || locked}
onClick={() => select(option.id)}
role="menuitemradio"
aria-checked={active}
>
<span>{option.name}</span>
{active && <Check size={16} aria-hidden="true" />}
</button>
);
})}
{(locked || diagnostic) && (
<p className="titlebar-view-menu-note">
{locked
? "i18n:govoplan-views.required_by_administrator"
: diagnostic?.message}
</p>
)}
{error && (
<DismissibleAlert tone="danger" compact resetKey={error}>
{error}
</DismissibleAlert>
)}
</div>
)}
</div>
);
}
+29 -26
View File
@@ -1,32 +1,39 @@
.titlebar-view-selector {
display: inline-flex;
.titlebar-view-menu {
width: min(300px, calc(100vw - 24px));
}
.titlebar-view-menu-heading {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 34px;
padding: 4px 9px 8px;
color: var(--text-strong);
}
.titlebar-view-menu .dropdown-item > span {
min-width: 0;
gap: 7px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.titlebar-view-selector select {
width: min(190px, 24vw);
height: 32px;
padding: 0 28px 0 8px;
border: 0;
border-radius: var(--radius-sm);
background-color: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
cursor: pointer;
.titlebar-view-menu .dropdown-item > svg {
flex: 0 0 auto;
margin-left: auto;
color: var(--accent);
}
.titlebar-view-selector select:hover,
.titlebar-view-selector select:focus-visible {
background-color: var(--titlebar-hover-bg);
}
.titlebar-view-selector select:disabled {
.titlebar-view-menu .dropdown-item:disabled {
cursor: default;
opacity: .78;
opacity: .72;
}
.titlebar-view-menu-note {
margin: 8px 9px 4px;
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.views-management-layout {
@@ -314,10 +321,6 @@
}
@media (max-width: 760px) {
.titlebar-view-selector select {
width: 120px;
}
.views-admin-shell {
display: flex;
flex-direction: column;