fix(ui): MediaGallery lightbox uses folds Overlay + FocusTrap (native-cinny audit 8/N)

The full-screen media viewer was a raw <div role="dialog"> rendered in place
with manual focus. Wrapped it in folds Overlay (portal + backdrop, proper
stacking) and FocusTrap (focus management), keeping its own arrow/Escape key
handling. The light-on-dark chrome (#fff over the forced-black media stage) is
kept — it's a justified, always-dark media-viewer scrim, not theme chrome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 14:49:24 -04:00
parent c5d7fcc303
commit c68ef346bf
+104 -91
View File
@@ -6,6 +6,8 @@ import {
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
Scroll,
Spinner,
Text,
@@ -15,6 +17,7 @@ import {
config,
} from 'folds';
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
@@ -250,102 +253,112 @@ function Lightbox({
});
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
role="dialog"
aria-modal
aria-label="Media viewer"
onKeyDown={handleKeyDown}
tabIndex={-1}
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: 'rgba(0,0,0,0.92)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header bar */}
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
<Overlay open backdrop={<OverlayBackdrop />}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
escapeDeactivates: false,
}}
>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
{item.sender} · {dateStr}
</Text>
</Box>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
{index + 1} / {items.length}
</Text>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
role="dialog"
aria-modal
aria-label="Media viewer"
onKeyDown={handleKeyDown}
tabIndex={-1}
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: 'rgba(0,0,0,0.92)',
display: 'flex',
flexDirection: 'column',
}}
>
{(ref) => (
<IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
{/* Header bar */}
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
}}
>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
{item.sender} · {dateStr}
</Text>
</Box>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
{index + 1} / {items.length}
</Text>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
{/* Media area with nav arrows */}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', padding: config.space.S400 }}
>
{index > 0 && (
<IconButton
variant="Surface"
aria-label="Previous"
onClick={prev}
style={{ flexShrink: 0, marginRight: config.space.S200 }}
{/* Media area with nav arrows */}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', padding: config.space.S400 }}
>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', height: '100%' }}
>
<LightboxMedia
key={`${item.mxcUrl}-${item.ts}`}
item={item}
useAuthentication={useAuthentication}
/>
</Box>
{index < items.length - 1 && (
<IconButton
variant="Surface"
aria-label="Next"
onClick={next}
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
>
<Icon src={Icons.ArrowRight} />
</IconButton>
)}
</Box>
</div>
{index > 0 && (
<IconButton
variant="Surface"
aria-label="Previous"
onClick={prev}
style={{ flexShrink: 0, marginRight: config.space.S200 }}
>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', height: '100%' }}
>
<LightboxMedia
key={`${item.mxcUrl}-${item.ts}`}
item={item}
useAuthentication={useAuthentication}
/>
</Box>
{index < items.length - 1 && (
<IconButton
variant="Surface"
aria-label="Next"
onClick={next}
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
>
<Icon src={Icons.ArrowRight} />
</IconButton>
)}
</Box>
</div>
</FocusTrap>
</Overlay>
);
}