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