CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Most modals rendered a folds Dialog/Modal with no role and no name, and their focus traps used `initialFocus: false`, so focus stayed behind the modal and a screen reader never announced it. - 32 dialogs with a visible heading: role="dialog", aria-modal, aria-labelledby → the heading (given an id), tabIndex=-1. - 4 dialogs that already had a name (Leave Room, room topic viewer, server ACL, room-nav prompt): role + aria-modal. - Their focus traps drop `initialFocus: false` for focus-trap's default (keep an already-focused autoFocus field, else the first tabbable element) with the dialog itself as fallbackFocus, so a dialog without a tabbable node can't crash the trap. Traps that live in a parent (UIA stages, Logout, Forward, Invite) get the semantics only. - The file drop overlay is deliberately left alone (not a dialog). Checked at runtime: Join with Address, Delete Message, Report Message, Leave Room and Logout open as named dialogs with focus inside and close with Escape (Tab first when a text field has focus — the shared stopPropagation keeps Escape from discarding typed text, by design). The axe e2e spec (6 tests) passes; eslint warnings unchanged (46). 17 modals with no heading (image/file viewers, loading screens) remain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
134 lines
4.2 KiB
TypeScript
134 lines
4.2 KiB
TypeScript
import React, { MouseEvent, ReactNode, useState } from 'react';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import {
|
|
Box,
|
|
Button,
|
|
Dialog,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
OverlayCenter,
|
|
Text,
|
|
color,
|
|
config,
|
|
} from 'folds';
|
|
import { stopPropagation } from '../../utils/keyboard';
|
|
import { LinkSafety } from '../../utils/linkSafety';
|
|
|
|
type SuspiciousLinkProps = {
|
|
href: string;
|
|
safety: LinkSafety;
|
|
anchorProps: Record<string, unknown>;
|
|
children: ReactNode;
|
|
};
|
|
|
|
/**
|
|
* [Gitea #122] A link whose visible text names a different site than its
|
|
* destination (or whose destination is a punycode host) opens a small
|
|
* confirm instead of navigating straight away. Honest links never see this.
|
|
*/
|
|
export function SuspiciousLink({ href, safety, anchorProps, children }: SuspiciousLinkProps) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const handleClick = (evt: MouseEvent) => {
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
setOpen(true);
|
|
};
|
|
const proceed = () => {
|
|
setOpen(false);
|
|
window.open(href, '_blank', 'noopener,noreferrer');
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<a
|
|
{...anchorProps}
|
|
href={href}
|
|
onClick={handleClick}
|
|
data-lotus-suspicious-link
|
|
title={`Goes to ${safety.realHost}`}
|
|
>
|
|
{children}
|
|
</a>
|
|
{open && (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
fallbackFocus: '#suspiciouslink-dialog-1',
|
|
onDeactivate: () => setOpen(false),
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Dialog
|
|
id="suspiciouslink-dialog-1"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="suspiciouslink-dialog-1-title"
|
|
tabIndex={-1}
|
|
variant="Surface"
|
|
style={{ maxWidth: 440 }}
|
|
>
|
|
<Header
|
|
style={{
|
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
|
borderBottomWidth: config.borderWidth.B300,
|
|
}}
|
|
variant="Surface"
|
|
size="500"
|
|
>
|
|
<Box grow="Yes">
|
|
<Text id="suspiciouslink-dialog-1-title" as="h2" size="H4">
|
|
This link doesn't go where it says
|
|
</Text>
|
|
</Box>
|
|
<IconButton
|
|
size="300"
|
|
onClick={() => setOpen(false)}
|
|
radii="300"
|
|
aria-label="Close"
|
|
>
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
</Header>
|
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
|
<Text>
|
|
{safety.shownHost ? (
|
|
<>
|
|
It shows <b>{safety.shownHost}</b> but goes to{' '}
|
|
<b style={{ color: color.Critical.Main }}>{safety.realHost}</b>.
|
|
</>
|
|
) : (
|
|
<>
|
|
It goes to <b style={{ color: color.Critical.Main }}>{safety.realHost}</b>.
|
|
</>
|
|
)}
|
|
{safety.punycode &&
|
|
' The destination uses look-alike (internationalised) characters in its name.'}
|
|
</Text>
|
|
<Text size="T200" priority="300" style={{ wordBreak: 'break-all' }}>
|
|
{href}
|
|
</Text>
|
|
<Box gap="200" justifyContent="End">
|
|
<Button variant="Secondary" fill="Soft" onClick={() => setOpen(false)}>
|
|
<Text size="B400">Cancel</Text>
|
|
</Button>
|
|
<Button variant="Critical" onClick={proceed}>
|
|
<Text size="B400">Open anyway</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
)}
|
|
</>
|
|
);
|
|
}
|