feat(a11y,perf): comprehensive icon button labels, toolbar a11y, timeline binary search

A11y C-1: aria-label on 30+ remaining icon-only buttons across:
  - settings panels (close, reset, info, expand, remove, undo)
  - editor toolbar (bold, italic, underline, strike, code, spoiler,
    blockquote, code block, ordered/unordered list, headings 1-3)
  - auth stages (cancel buttons in SSO, Password stages)
  - device verification (cancel buttons)
  - password input (show/hide toggle with dynamic label)
  - event readers, account data editor close buttons
  - global emoji packs (add/remove buttons)
Perf-5: Replace O(N×T) getTimelineAndBaseIndex scan with precomputed binary
  search (timelineSegments useMemo) — O(log T) per visible message render

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lotus Bot
2026-05-20 21:54:33 -04:00
parent d4705f9235
commit 4e80c0a0f5
23 changed files with 89 additions and 30 deletions
+25 -1
View File
@@ -546,6 +546,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timelineRef = React.useRef(timeline);
timelineRef.current = timeline;
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
// Perf-5: precompute base offsets once per linkedTimelines change instead of O(N×T) scan
const timelineSegments = useMemo<Array<[number, number, EventTimeline]>>(() => {
let base = 0;
return timeline.linkedTimelines.map((t) => {
const len = t.getEvents().length;
const seg: [number, number, EventTimeline] = [base, len, t];
base += len;
return seg;
});
}, [timeline.linkedTimelines]);
const liveTimelineLinked =
timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
const canPaginateBack =
@@ -1809,7 +1820,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
let newDivider = false;
let dayDivider = false;
const eventRenderer = (item: number) => {
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
// Perf-5: O(T) → O(log T) via precomputed segments
let eventTimeline: EventTimeline | undefined;
let baseIndex = 0;
{
let lo = 0;
let hi = timelineSegments.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const [base, len] = timelineSegments[mid];
if (item < base) { hi = mid - 1; }
else if (item >= base + len) { lo = mid + 1; }
else { eventTimeline = timelineSegments[mid][2]; baseIndex = base; break; }
}
}
if (!eventTimeline) return null;
const timelineSet = eventTimeline?.getTimelineSet();
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
+1 -1
View File
@@ -141,7 +141,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background">
<IconButton onClick={requestClose} variant="Background" aria-label="Close settings">
<Icon src={Icons.Cross} />
</IconButton>
)}
+1 -1
View File
@@ -25,7 +25,7 @@ export function About({ requestClose }: AboutProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
@@ -20,7 +20,7 @@ export function Account({ requestClose }: AccountProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
@@ -72,6 +72,7 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
size="300"
radii="300"
variant="Secondary"
aria-label="Clear"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
@@ -185,7 +185,7 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
<Box grow="Yes">
<Text size="H4">Remove Avatar</Text>
</Box>
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -278,6 +278,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
size="300"
radii="300"
variant="Secondary"
aria-label="Reset display name"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
@@ -51,7 +51,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
@@ -292,6 +292,8 @@ export function DeviceTile({
outlined={deleted}
radii="300"
onClick={() => setDetails(!details)}
aria-label={details ? 'Collapse device details' : 'Expand device details'}
aria-expanded={details}
>
<Icon size="50" src={details ? Icons.ChevronBottom : Icons.ChevronRight} />
</IconButton>
@@ -74,7 +74,7 @@ export function Devices({ requestClose }: DevicesProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
@@ -293,6 +293,7 @@ export function DeviceVerificationOptions() {
size="300"
radii="300"
onClick={handleMenu}
aria-label="Verification options"
>
<Icon size="100" src={Icons.VerticalDots} />
</IconButton>
@@ -30,7 +30,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
@@ -373,6 +373,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
variant="Critical"
onClick={() => handleUndoRemove(address)}
disabled={applyingChanges}
aria-label="Undo remove pack"
>
<Icon src={Icons.Plus} size="100" />
</IconButton>
@@ -383,6 +384,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
variant="Secondary"
onClick={() => handleRemove(address)}
disabled={applyingChanges}
aria-label="Remove pack"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
@@ -1184,7 +1184,7 @@ export function General({ requestClose }: GeneralProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
@@ -80,6 +80,7 @@ function KeywordInput() {
size="300"
radii="300"
variant="Secondary"
aria-label="Clear keyword input"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
@@ -118,7 +119,7 @@ function KeywordCross({ pushRule }: PushRulesProps) {
const removing = removeState.status === AsyncStatus.Loading;
return (
<IconButton onClick={remove} size="300" radii="Pill" variant="Secondary" disabled={removing}>
<IconButton onClick={remove} size="300" radii="Pill" variant="Secondary" disabled={removing} aria-label="Remove keyword">
{removing ? <Spinner size="100" /> : <Icon src={Icons.Cross} size="100" />}
</IconButton>
);
@@ -23,7 +23,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>