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
+4
View File
@@ -19,6 +19,10 @@ Views are presentation filters, never security boundaries. Hidden routes still
use their normal permission checks. Required Views retain the View selector and
administration surfaces needed to inspect and change the assignment, preventing
administrators from locking an installation out of its own configuration.
The selector is the titlebar eye button next to the language control. It opens
the available-View menu and uses the accent color whenever a specialized View
is active; the full interface keeps the icon neutral. The selector announces
the `views.selector` help context so `F1` opens the matching Views guidance.
Views are also the canonical user/group module-visibility mechanism. Core
creates a root `<module>.module` surface for every WebUI module, so a View may
+1 -1
View File
@@ -9,7 +9,7 @@ assignments, effective selection, and the safeguards described here.
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `views.selector` | Compact global selector | Change active presentation projection | Localized accessible selector, required-state explanation, unsaved-change guard |
| `views.selector` | Compact titlebar eye menu | Change active presentation projection | Neutral full-interface state, accented specialized state, localized accessible menu, required-state explanation, unsaved-change guard |
| `views.admin.system` | Administration list-detail editor | Create, publish, archive, assign | Shared admin layout, explicit disabled reasons, required-View lockout validation, contextual help |
| `views.admin.tenant` | Administration list-detail editor | Create, publish, archive, assign | Inheritance provenance, searchable group/user targets, explicit optional-module blocker |
| `views.settings.personal` | Settings list-detail editor | Create and publish owned Views | Shared owner selector, permission blocker, contextual field help |
+4 -2
View File
@@ -303,8 +303,10 @@ manifest = ModuleManifest(
"and tenant administrators can publish Views and make them "
"available, default, or required at system, tenant, group, and "
"user scope. Required Views retain administration escape surfaces "
"so they can always be inspected and changed. Hidden functions "
"remain protected by their normal permission checks."
"so they can always be inspected and changed. The titlebar eye "
"opens the selector and is accented while a specialized View is "
"active. Hidden functions remain protected by their normal "
"permission checks."
),
layer="available",
documentation_types=("admin", "user"),
@@ -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;