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
+82 -8
View File
@@ -1,7 +1,9 @@
import {
CalendarDays,
Files,
Grid2X2,
ListChecks,
ListFilter,
MessagesSquare,
Settings2,
X,
@@ -15,6 +17,7 @@ import {
IconButton,
LoadingFrame,
dispatchQuickAccessResult,
i18nMessage,
quickAccessLaunchState,
useGuardedNavigate,
usePlatformLanguage,
@@ -39,6 +42,7 @@ const iconByCategory: Record<string, LucideIcon> = {
export default function QuickAccessRail({ settings, auth, tools, launchContext }: QuickAccessRailProps) {
const [effective, setEffective] = useState<EffectiveQuickAccess | null>(null);
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
const [showAllAvailable, setShowAllAvailable] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const drawerRef = useRef<HTMLDivElement>(null);
@@ -61,8 +65,8 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
() => new Set(launchContext.viewContext?.recommendedToolIds ?? []),
[launchContext.viewContext?.recommendedToolIds]
);
const categories = useMemo(() => {
const eligibleCategories = (effective?.categories ?? [])
const eligibleCategories = useMemo(() =>
(effective?.categories ?? [])
.filter((category) => category.enabled)
.map((category) => ({
...category,
@@ -76,14 +80,30 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
}
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))
);
),
[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) => ({
...category,
tools: category.tools.filter((tool) =>
!hasEligibleFocus || focusedToolIds.has(tool.id)
!hasEligibleFocus || showAllAvailable || focusedToolIds.has(tool.id)
).sort((left, right) =>
Number(recommendedToolIds.has(right.id)) - Number(recommendedToolIds.has(left.id))
|| left.order - right.order
@@ -91,7 +111,7 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
)
})).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;
@@ -143,6 +163,10 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
setActiveCategoryId(null);
}, [location.pathname, location.search]);
useEffect(() => {
setShowAllAvailable(false);
}, [launchContext.viewContext?.viewId, launchContext.viewContext?.revisionId]);
if (!loading && categories.length === 0 && !error) return null;
function closeDrawer(restoreFocus = true) {
@@ -169,9 +193,37 @@ export default function QuickAccessRail({ settings, auth, tools, 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 (
<>
<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">
{loading ? <span className="quick-access-rail-loading" aria-label="i18n:govoplan-quick-access.loading" /> : null}
{categories.map((category) => {
@@ -193,6 +245,20 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
);
})}
</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
className="quick-access-rail-button quick-access-settings-link"
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()} />
</header>
<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}
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
{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.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.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.settings_title": "Schnellzugriff",
"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.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.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.settings_title": "Quick Access",
"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;
}
.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 {
width: 18px;
height: 18px;
@@ -117,6 +126,36 @@
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 {
display: grid;
gap: 12px;
@@ -270,6 +309,14 @@
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 {
right: 0;
bottom: 50px;
@@ -281,6 +328,11 @@
box-shadow: var(--shadow-drawer-bottom);
}
.quick-access-view-mode {
align-items: flex-start;
flex-direction: column;
}
.quick-access-preference-row {
align-items: stretch;
flex-direction: column;