feat(webui): add View focus escape to Quick Access

This commit is contained in:
2026-08-19 20:14:02 +02:00
parent 5a906185a6
commit b5b629b357
5 changed files with 163 additions and 11 deletions
+7 -1
View File
@@ -43,7 +43,13 @@ identifiers, temporal selection, exact View identity, View recommendations or
focus, and a safe return route. Cross-tenant object references are discarded focus, and a safe return route. Cross-tenant object references are discarded
and unknown versions fail closed. View focus applies only when at least one and unknown versions fail closed. View focus applies only when at least one
focused tool is currently enabled, context-compatible, and authorized; a focused tool is currently enabled, context-compatible, and authorized; a
recommendation only changes ordering and emphasis. recommendation only changes ordering and emphasis. The rail identifies an
active View focus and offers **All available tools** as a temporary escape to
the complete permission-derived catalogue. This does not change the active
View, persist an override, or expose a tool that failed installation,
entitlement, policy, preference, context, surface, or permission checks. If no
focused tool is eligible, the rail explains that stale focus and falls back to
the permission-derived catalogue instead of becoming empty.
Each renderer performs owner-side reads and effects and explicitly reports Each renderer performs owner-side reads and effects and explicitly reports
either a result-contract-version-1 completion with a typed owner reference or either a result-contract-version-1 completion with a typed owner reference or
+10 -2
View File
@@ -121,6 +121,9 @@ DOCUMENTATION = (
"Messages combines enabled Mail, Postbox, and future chat contributions in one overlay. " "Messages combines enabled Mail, Postbox, and future chat contributions in one overlay. "
"Personal settings can reorder or hide items that remain available under system, tenant, " "Personal settings can reorder or hide items that remain available under system, tenant, "
"permission, and View policy. Every item retains a link to its complete owning page. " "permission, and View policy. Every item retains a link to its complete owning page. "
"When a View focuses the rail, the rail names that View and offers a temporary All available tools escape that "
"shows the complete permission-derived catalogue without changing the View or personal settings. If none of a "
"View's focused tools is currently available, Quick Access explains the stale focus and falls back to that catalogue. "
"Launch-context version 2 carries only versioned, bounded references to the current object, acting assignment, " "Launch-context version 2 carries only versioned, bounded references to the current object, acting assignment, "
"temporal selection, exact View revision, and return location. Owner modules recheck access when a tool opens and " "temporal selection, exact View revision, and return location. Owner modules recheck access when a tool opens and "
"before each effect. A tool returns either an explicit version-1 completion with a typed owner reference or an " "before each effect. A tool returns either an explicit version-1 completion with a typed owner reference or an "
@@ -145,6 +148,9 @@ DOCUMENTATION = (
"Eine Kategorie in der rechten Leiste oeffnet kompakte Werkzeuge, ohne die aktuelle Aufgabe zu verlassen. " "Eine Kategorie in der rechten Leiste oeffnet kompakte Werkzeuge, ohne die aktuelle Aufgabe zu verlassen. "
"Nachrichten fuehrt Beitraege aus Mail, Postfach und kuenftigen Chat-Modulen in einer Einblendung zusammen. " "Nachrichten fuehrt Beitraege aus Mail, Postfach und kuenftigen Chat-Modulen in einer Einblendung zusammen. "
"Persoenliche Einstellungen koennen alle durch System, Mandant, Berechtigungen und Ansicht zugelassenen Eintraege ordnen oder ausblenden. " "Persoenliche Einstellungen koennen alle durch System, Mandant, Berechtigungen und Ansicht zugelassenen Eintraege ordnen oder ausblenden. "
"Wenn eine Ansicht die Leiste fokussiert, benennt die Leiste diese Ansicht und bietet voruebergehend alle "
"verfuegbaren Werkzeuge an, ohne Ansicht oder persoenliche Einstellungen zu aendern. Ist keines der fokussierten "
"Werkzeuge verfuegbar, wird der veraltete Fokus erklaert und der berechtigungsabgeleitete Katalog angezeigt. "
"Startkontext Version 2 uebergibt nur versionierte, begrenzte Verweise auf Objekt, handelnde Zuordnung, " "Startkontext Version 2 uebergibt nur versionierte, begrenzte Verweise auf Objekt, handelnde Zuordnung, "
"Zeitbezug, genaue Ansichtsversion und Ruecksprungort. Das besitzende Modul prueft den Zugriff beim Oeffnen " "Zeitbezug, genaue Ansichtsversion und Ruecksprungort. Das besitzende Modul prueft den Zugriff beim Oeffnen "
"und vor jeder Wirkung erneut. Ein Werkzeug meldet entweder einen ausdruecklichen Abschluss mit typisiertem " "und vor jeder Wirkung erneut. Ein Werkzeug meldet entweder einen ausdruecklichen Abschluss mit typisiertem "
@@ -170,7 +176,8 @@ DOCUMENTATION = (
"tenant settings constrain users. An item may remain available, be blocked, or be forced. " "tenant settings constrain users. An item may remain available, be blocked, or be forced. "
"Views and permissions form additional ceilings and Quick Access never grants access to domain data. " "Views and permissions form additional ceilings and Quick Access never grants access to domain data. "
"A View may recommend tools or focus the rail to a subset, but only currently enabled, context-compatible, authorized " "A View may recommend tools or focus the rail to a subset, but only currently enabled, context-compatible, authorized "
"tools participate. Workflow receives that same presentation from the exact resolved View revision. Launch-context " "tools participate. The All available tools escape only restores that permission-derived set for the current session; "
"it never broadens authorization or persists an override. Workflow receives that same presentation from the exact resolved View revision. Launch-context "
"version 2, reference contract version 1, and result contract version 1 fail closed on unknown versions; cross-tenant " "version 2, reference contract version 1, and result contract version 1 fail closed on unknown versions; cross-tenant "
"active-object and result references are rejected." "active-object and result references are rejected."
), ),
@@ -186,7 +193,8 @@ DOCUMENTATION = (
"Mandanteneinstellungen begrenzen Benutzer. Ein Eintrag kann verfuegbar, gesperrt oder erzwungen sein. " "Mandanteneinstellungen begrenzen Benutzer. Ein Eintrag kann verfuegbar, gesperrt oder erzwungen sein. "
"Ansichten und Berechtigungen bilden weitere Grenzen; Schnellzugriff erteilt selbst keinen Datenzugriff. " "Ansichten und Berechtigungen bilden weitere Grenzen; Schnellzugriff erteilt selbst keinen Datenzugriff. "
"Eine Ansicht darf Werkzeuge empfehlen oder die Leiste auf eine Teilmenge fokussieren, jedoch nur innerhalb " "Eine Ansicht darf Werkzeuge empfehlen oder die Leiste auf eine Teilmenge fokussieren, jedoch nur innerhalb "
"der aktivierten, kontextgeeigneten und berechtigten Werkzeuge. Workflow verwendet dieselbe Darstellung aus " "der aktivierten, kontextgeeigneten und berechtigten Werkzeuge. Alle verfuegbaren Werkzeuge stellt nur diese "
"berechtigungsabgeleitete Menge fuer die laufende Sitzung wieder her und speichert keine Umgehung. Workflow verwendet dieselbe Darstellung aus "
"der genau aufgeloesten Ansichtsversion. Startkontext Version 2 sowie Verweis- und Ergebniskontrakt Version 1 " "der genau aufgeloesten Ansichtsversion. Startkontext Version 2 sowie Verweis- und Ergebniskontrakt Version 1 "
"lehnen unbekannte Versionen ab; mandantenfremde Objekt- und Ergebnisverweise werden verworfen." "lehnen unbekannte Versionen ab; mandantenfremde Objekt- und Ergebnisverweise werden verworfen."
), ),
+82 -8
View File
@@ -1,7 +1,9 @@
import { import {
CalendarDays, CalendarDays,
Files, Files,
Grid2X2,
ListChecks, ListChecks,
ListFilter,
MessagesSquare, MessagesSquare,
Settings2, Settings2,
X, X,
@@ -15,6 +17,7 @@ import {
IconButton, IconButton,
LoadingFrame, LoadingFrame,
dispatchQuickAccessResult, dispatchQuickAccessResult,
i18nMessage,
quickAccessLaunchState, quickAccessLaunchState,
useGuardedNavigate, useGuardedNavigate,
usePlatformLanguage, usePlatformLanguage,
@@ -39,6 +42,7 @@ const iconByCategory: Record<string, LucideIcon> = {
export default function QuickAccessRail({ settings, auth, tools, launchContext }: QuickAccessRailProps) { export default function QuickAccessRail({ settings, auth, tools, launchContext }: QuickAccessRailProps) {
const [effective, setEffective] = useState<EffectiveQuickAccess | null>(null); const [effective, setEffective] = useState<EffectiveQuickAccess | null>(null);
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null); const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
const [showAllAvailable, setShowAllAvailable] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const drawerRef = useRef<HTMLDivElement>(null); const drawerRef = useRef<HTMLDivElement>(null);
@@ -61,8 +65,8 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
() => new Set(launchContext.viewContext?.recommendedToolIds ?? []), () => new Set(launchContext.viewContext?.recommendedToolIds ?? []),
[launchContext.viewContext?.recommendedToolIds] [launchContext.viewContext?.recommendedToolIds]
); );
const categories = useMemo(() => { const eligibleCategories = useMemo(() =>
const eligibleCategories = (effective?.categories ?? []) (effective?.categories ?? [])
.filter((category) => category.enabled) .filter((category) => category.enabled)
.map((category) => ({ .map((category) => ({
...category, ...category,
@@ -76,14 +80,30 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
} }
return true; return true;
}) })
})); }))
const hasEligibleFocus = eligibleCategories.some((category) => .filter((category) => category.tools.length > 0),
[availableToolIds, effective, launchContext.activeObject, metadataById]
);
const hasEligibleFocus = useMemo(
() => eligibleCategories.some((category) =>
category.tools.some((tool) => focusedToolIds.has(tool.id)) category.tools.some((tool) => focusedToolIds.has(tool.id))
); ),
[eligibleCategories, focusedToolIds]
);
const hiddenByFocusCount = useMemo(
() => hasEligibleFocus
? eligibleCategories.reduce(
(count, category) => count + category.tools.filter((tool) => !focusedToolIds.has(tool.id)).length,
0
)
: 0,
[eligibleCategories, focusedToolIds, hasEligibleFocus]
);
const categories = useMemo(() => {
return eligibleCategories.map((category) => ({ return eligibleCategories.map((category) => ({
...category, ...category,
tools: category.tools.filter((tool) => tools: category.tools.filter((tool) =>
!hasEligibleFocus || focusedToolIds.has(tool.id) !hasEligibleFocus || showAllAvailable || focusedToolIds.has(tool.id)
).sort((left, right) => ).sort((left, right) =>
Number(recommendedToolIds.has(right.id)) - Number(recommendedToolIds.has(left.id)) Number(recommendedToolIds.has(right.id)) - Number(recommendedToolIds.has(left.id))
|| left.order - right.order || left.order - right.order
@@ -91,7 +111,7 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
) )
})).filter((category) => category.enabled && category.tools.length > 0); })).filter((category) => category.enabled && category.tools.length > 0);
}, },
[availableToolIds, effective, focusedToolIds, launchContext.activeObject, metadataById, recommendedToolIds] [eligibleCategories, focusedToolIds, hasEligibleFocus, recommendedToolIds, showAllAvailable]
); );
const activeCategory = categories.find((category) => category.id === activeCategoryId) ?? null; const activeCategory = categories.find((category) => category.id === activeCategoryId) ?? null;
@@ -143,6 +163,10 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
setActiveCategoryId(null); setActiveCategoryId(null);
}, [location.pathname, location.search]); }, [location.pathname, location.search]);
useEffect(() => {
setShowAllAvailable(false);
}, [launchContext.viewContext?.viewId, launchContext.viewContext?.revisionId]);
if (!loading && categories.length === 0 && !error) return null; if (!loading && categories.length === 0 && !error) return null;
function closeDrawer(restoreFocus = true) { function closeDrawer(restoreFocus = true) {
@@ -169,9 +193,37 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
navigate(path, { state: quickAccessLaunchState(launchContext) }); navigate(path, { state: quickAccessLaunchState(launchContext) });
} }
function toggleViewFocus() {
setShowAllAvailable((current) => !current);
}
const viewName = launchContext.viewContext?.name?.trim()
|| translateText("i18n:govoplan-quick-access.current_view");
const viewFocusRequested = focusedToolIds.size > 0;
const canToggleViewFocus = hasEligibleFocus && hiddenByFocusCount > 0;
const viewModeLabel = showAllAvailable
? translateText("i18n:govoplan-quick-access.restore_view_focus")
: translateText("i18n:govoplan-quick-access.show_all_available");
const viewModeExplanation = !hasEligibleFocus
? translateText(i18nMessage("i18n:govoplan-quick-access.focus_unavailable", { value0: viewName }))
: showAllAvailable
? translateText(i18nMessage("i18n:govoplan-quick-access.showing_all_for_view", { value0: viewName }))
: translateText(i18nMessage("i18n:govoplan-quick-access.focused_by_view", { value0: viewName }));
const viewFocusMode = !viewFocusRequested
? "none"
: !hasEligibleFocus
? "unavailable"
: showAllAvailable
? "all"
: "focused";
return ( return (
<> <>
<aside className="quick-access-rail" aria-label="i18n:govoplan-quick-access.quick_access"> <aside
className="quick-access-rail"
aria-label="i18n:govoplan-quick-access.quick_access"
data-view-focus-mode={viewFocusMode}
>
<div className="quick-access-rail-tools"> <div className="quick-access-rail-tools">
{loading ? <span className="quick-access-rail-loading" aria-label="i18n:govoplan-quick-access.loading" /> : null} {loading ? <span className="quick-access-rail-loading" aria-label="i18n:govoplan-quick-access.loading" /> : null}
{categories.map((category) => { {categories.map((category) => {
@@ -193,6 +245,20 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
); );
})} })}
</div> </div>
{canToggleViewFocus ? (
<button
type="button"
className={`quick-access-rail-button quick-access-view-mode-button${showAllAvailable ? " active" : ""}`}
title={viewModeLabel}
aria-label={viewModeLabel}
aria-pressed={showAllAvailable}
onClick={toggleViewFocus}
>
{showAllAvailable
? <ListFilter size={19} aria-hidden="true" />
: <Grid2X2 size={19} aria-hidden="true" />}
</button>
) : null}
<Link <Link
className="quick-access-rail-button quick-access-settings-link" className="quick-access-rail-button quick-access-settings-link"
to="/settings?section=quick-access" to="/settings?section=quick-access"
@@ -220,6 +286,14 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
<IconButton label="i18n:govoplan-quick-access.close" icon={<X size={18} />} variant="ghost" onClick={() => closeDrawer()} /> <IconButton label="i18n:govoplan-quick-access.close" icon={<X size={18} />} variant="ghost" onClick={() => closeDrawer()} />
</header> </header>
<div className="quick-access-drawer-content"> <div className="quick-access-drawer-content">
{viewFocusRequested ? (
<div className="quick-access-view-mode" role="status" data-view-focus-mode={viewFocusMode}>
<span>{viewModeExplanation}</span>
{canToggleViewFocus ? (
<button type="button" onClick={toggleViewFocus}>{viewModeLabel}</button>
) : null}
</div>
) : null}
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null} {error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading"> <LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
{activeCategory.tools.map((tool) => { {activeCategory.tools.map((tool) => {
+12
View File
@@ -9,6 +9,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-quick-access.open_full_page": "Vollständige Seite öffnen", "i18n:govoplan-quick-access.open_full_page": "Vollständige Seite öffnen",
"i18n:govoplan-quick-access.compact_view_unavailable": "Die kompakte Ansicht ist nicht verfügbar. Verwenden Sie die vollständige Seite.", "i18n:govoplan-quick-access.compact_view_unavailable": "Die kompakte Ansicht ist nicht verfügbar. Verwenden Sie die vollständige Seite.",
"i18n:govoplan-quick-access.recommended_for_view": "Für diese Ansicht empfohlen", "i18n:govoplan-quick-access.recommended_for_view": "Für diese Ansicht empfohlen",
"i18n:govoplan-quick-access.current_view": "aktuelle Ansicht",
"i18n:govoplan-quick-access.show_all_available": "Alle verfügbaren Werkzeuge anzeigen",
"i18n:govoplan-quick-access.restore_view_focus": "Ansichtsfokus wiederherstellen",
"i18n:govoplan-quick-access.focused_by_view": "Durch die Ansicht {value0} fokussiert. Weitere berechtigte Werkzeuge bleiben verfügbar.",
"i18n:govoplan-quick-access.showing_all_for_view": "Alle für Sie verfügbaren Werkzeuge werden angezeigt; die Ansicht {value0} bleibt aktiv.",
"i18n:govoplan-quick-access.focus_unavailable": "Der Werkzeugfokus der Ansicht {value0} ist derzeit nicht verfügbar. Alle berechtigten Werkzeuge werden angezeigt.",
"i18n:govoplan-quick-access.invalid_result": "Das Werkzeug hat ein ungültiges oder nicht zugelassenes Ergebnis zurückgegeben.", "i18n:govoplan-quick-access.invalid_result": "Das Werkzeug hat ein ungültiges oder nicht zugelassenes Ergebnis zurückgegeben.",
"i18n:govoplan-quick-access.settings_title": "Schnellzugriff", "i18n:govoplan-quick-access.settings_title": "Schnellzugriff",
"i18n:govoplan-quick-access.personal_help": "Werkzeuge auswählen und ordnen, die neben der aktuellen Arbeit verfügbar bleiben.", "i18n:govoplan-quick-access.personal_help": "Werkzeuge auswählen und ordnen, die neben der aktuellen Arbeit verfügbar bleiben.",
@@ -48,6 +54,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-quick-access.open_full_page": "Open full page", "i18n:govoplan-quick-access.open_full_page": "Open full page",
"i18n:govoplan-quick-access.compact_view_unavailable": "The compact view is unavailable. Use the full page.", "i18n:govoplan-quick-access.compact_view_unavailable": "The compact view is unavailable. Use the full page.",
"i18n:govoplan-quick-access.recommended_for_view": "Recommended for this View", "i18n:govoplan-quick-access.recommended_for_view": "Recommended for this View",
"i18n:govoplan-quick-access.current_view": "current View",
"i18n:govoplan-quick-access.show_all_available": "Show all available tools",
"i18n:govoplan-quick-access.restore_view_focus": "Restore View focus",
"i18n:govoplan-quick-access.focused_by_view": "Focused by the {value0} View. Other authorized tools remain available.",
"i18n:govoplan-quick-access.showing_all_for_view": "Showing all tools available to you; the {value0} View remains active.",
"i18n:govoplan-quick-access.focus_unavailable": "The {value0} View's tool focus is currently unavailable. All authorized tools are shown.",
"i18n:govoplan-quick-access.invalid_result": "The tool returned an invalid or undeclared result.", "i18n:govoplan-quick-access.invalid_result": "The tool returned an invalid or undeclared result.",
"i18n:govoplan-quick-access.settings_title": "Quick Access", "i18n:govoplan-quick-access.settings_title": "Quick Access",
"i18n:govoplan-quick-access.personal_help": "Choose and order the tools kept beside your current work.", "i18n:govoplan-quick-access.personal_help": "Choose and order the tools kept beside your current work.",
+52
View File
@@ -55,6 +55,15 @@
margin: auto 4px 6px; margin: auto 4px 6px;
} }
.quick-access-view-mode-button {
flex: 0 0 auto;
margin: auto 4px 3px;
}
.quick-access-view-mode-button + .quick-access-settings-link {
margin-top: 0;
}
.quick-access-rail-loading { .quick-access-rail-loading {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -117,6 +126,36 @@
overflow-y: auto; overflow-y: auto;
} }
.quick-access-view-mode {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-bottom: var(--border-line);
padding: 9px 12px 9px 16px;
background: var(--accent-soft);
color: var(--text);
font-size: 12px;
line-height: 1.4;
}
.quick-access-view-mode button {
flex: 0 0 auto;
border: 0;
padding: 2px 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 700;
}
.quick-access-view-mode button:hover,
.quick-access-view-mode button:focus-visible {
text-decoration: underline;
}
.quick-access-tool { .quick-access-tool {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -270,6 +309,14 @@
margin: 4px 6px 4px auto; margin: 4px 6px 4px auto;
} }
.quick-access-view-mode-button {
margin: 4px 3px 4px auto;
}
.quick-access-view-mode-button + .quick-access-settings-link {
margin-left: 0;
}
.quick-access-drawer { .quick-access-drawer {
right: 0; right: 0;
bottom: 50px; bottom: 50px;
@@ -281,6 +328,11 @@
box-shadow: var(--shadow-drawer-bottom); box-shadow: var(--shadow-drawer-bottom);
} }
.quick-access-view-mode {
align-items: flex-start;
flex-direction: column;
}
.quick-access-preference-row { .quick-access-preference-row {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;