fix(media viewer): lightbox focuses itself on open (keys worked only after a click); timeline viewer gets +/-/0 keys, double-click zoom and dialog semantics (#164)
CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 5s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m19s

Gallery lightbox: FocusTrap had initialFocus:false, so focus stayed on the
tile behind the overlay and ←/→/Esc/+/- were dead until the user clicked
inside. Timeline ImageViewer: no keyboard zoom, no role/aria-modal/label,
focus landed on the <img>; now mirrors the lightbox (+ = / - / 0,
double-click toggles 1×↔2×, role=dialog aria-modal labelled by the file
name, focuses on open).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-18 21:29:24 -04:00
co-authored by Claude Opus 5
parent f2f498425b
commit 53823f5466
2 changed files with 35 additions and 3 deletions
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
@@ -27,12 +27,37 @@ export const ImageViewer = as<'div', ImageViewerProps>(
saveFile(fileContent, alt);
};
// [Gitea #164] Same keyboard model as the gallery lightbox: + / = zoom
// in, - zoom out, 0 reset; double-click toggles 1× ↔ 2×. The root takes
// focus on open so those keys (and Escape, handled by the modal) work
// immediately instead of only after clicking inside the viewer.
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
rootRef.current?.focus({ preventScroll: true });
}, []);
const handleKeyDown = (evt: React.KeyboardEvent) => {
if (evt.key === '+' || evt.key === '=') zoomIn();
else if (evt.key === '-') zoomOut();
else if (evt.key === '0') setZoom(1);
else return;
evt.preventDefault();
};
return (
<Box
className={classNames(css.ImageViewer, className)}
direction="Column"
role="dialog"
aria-modal
aria-label={alt || 'Image viewer'}
tabIndex={-1}
onKeyDown={handleKeyDown}
{...props}
ref={ref}
ref={(node: HTMLDivElement | null) => {
rootRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
>
<Header className={css.ImageViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
@@ -116,6 +141,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
alt={alt}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onDoubleClick={() => setZoom(zoom === 1 ? 2 : 1)}
/>
</Box>
</Box>
+7 -1
View File
@@ -302,6 +302,7 @@ function Lightbox({
// Pan is only active for a zoomed-in image; usePan resets its offset when this
// flips false (i.e. back to 1x, on navigation, or on a video).
const { pan, cursor, onMouseDown, onTouchStart } = usePan(isImage && zoom !== 1);
const dialogRef = useRef<HTMLDivElement>(null);
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
// Reset zoom when navigating to another item (and thus pan, via usePan).
@@ -346,12 +347,17 @@ function Lightbox({
<Overlay open backdrop={<OverlayBackdrop />}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// Focus the dialog itself on open: with `initialFocus: false` focus
// stayed on the gallery tile behind the overlay, so ←/→/Esc/+/-
// (handled by onKeyDown below) did nothing until the user clicked
// inside the viewer (Gitea #164).
initialFocus: () => dialogRef.current ?? false,
clickOutsideDeactivates: false,
escapeDeactivates: false,
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal
aria-label="Media viewer"