fix(ui): report category dropdown uses folds menu, not native select (N56)
CI / Build & Quality Checks (push) Successful in 10m52s
CI / Trigger Desktop Build (push) Successful in 7s

Extract a shared ReportCategorySelect: folds Button trigger + PopOut +
FocusTrap + Menu + MenuItem (escape + arrow-key nav, like OrderButton),
replacing the OS-styled native <select> in both ReportRoomModal and
ReportUserModal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 20:41:46 -04:00
parent 374d6dc396
commit 5470e25bb0
4 changed files with 96 additions and 48 deletions
+1 -2
View File
@@ -397,9 +397,8 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
**N56 — Report Modal Category Dropdown: Native `<select>` Instead of folds `Chip`+`PopOut`+`Menu`**
- **File:** `src/app/features/room/ReportRoomModal.tsx` lines 138163; `src/app/features/room/ReportUserModal.tsx` lines 144169
- **Status:** **OPEN**
- **Status:** **FIXED** — extracted a shared `ReportCategorySelect` component (`src/app/features/room/ReportCategorySelect.tsx`) using the folds `Button` trigger + `PopOut` + `FocusTrap` + `Menu` + `MenuItem` pattern (with `escapeDeactivates`/arrow-key nav, matching `OrderButton`); both modals now use it instead of the native `<select>`.
- **Issue:** Both report modals render the "Category" field as `<Box as="select">` with hand-rolled inline styles (padding, border, background, color, fontSize, fontFamily). No other selector in the message-action modal context uses `<select>` — the established pattern for all dropdowns in both message modals and search filters is `Chip onClick → setMenuAnchor → PopOut → FocusTrap → Menu → MenuItem` (reference: `OrderButton` in `SearchFilters.tsx` lines 63114).
- **Fix:** Replace native `<select>` with `<Chip>` trigger + `<PopOut>` + `<Menu>` + `<MenuItem>` pattern.
---
@@ -0,0 +1,85 @@
import React, { MouseEventHandler, useState } from 'react';
import { Box, Button, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config } from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../../utils/keyboard';
type ReportCategorySelectProps = {
id?: string;
value: string;
labels: Record<string, string>;
onChange: (value: string) => void;
};
/**
* Category dropdown for the report modals folds `Button` + `PopOut` + `Menu`
* pattern (matching `OrderButton` in SearchFilters), replacing the OS-styled
* native `<select>` that looked foreign inside the modal.
*/
export function ReportCategorySelect({ id, value, labels, onChange }: ReportCategorySelectProps) {
const [anchor, setAnchor] = useState<RectCords>();
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
setAnchor(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (key: string) => {
onChange(key);
setAnchor(undefined);
};
return (
<PopOut
anchor={anchor}
position="Bottom"
align="Start"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
}}
>
<Menu style={{ padding: config.space.S100 }}>
<Box direction="Column" gap="100">
{Object.keys(labels).map((key) => (
<MenuItem
key={key}
size="300"
variant={key === value ? 'Primary' : 'Surface'}
radii="300"
aria-pressed={key === value}
onClick={() => handleSelect(key)}
>
<Text size="T300">{labels[key]}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
>
<Button
id={id}
type="button"
variant="Secondary"
fill="Soft"
size="400"
radii="300"
outlined
onClick={handleOpen}
aria-haspopup="listbox"
aria-expanded={!!anchor}
after={<Icon size="100" src={Icons.ChevronBottom} />}
style={{ width: '100%', justifyContent: 'space-between' }}
>
<Text size="T300">{labels[value] ?? value}</Text>
</Button>
</PopOut>
);
}
+5 -23
View File
@@ -18,6 +18,7 @@ import {
Spinner,
} from 'folds';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ReportCategorySelect } from './ReportCategorySelect';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
@@ -131,31 +132,12 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
<Text as="label" htmlFor="report-category" size="L400">
Category
</Text>
<Box
as="select"
<ReportCategorySelect
id="report-category"
aria-label="Report category"
value={category}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setCategory(e.target.value as ReportCategory)
}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
color: color.Surface.OnContainer,
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
}}
>
{(Object.keys(CATEGORY_LABELS) as ReportCategory[]).map((key) => (
<option key={key} value={key}>
{CATEGORY_LABELS[key]}
</option>
))}
</Box>
labels={CATEGORY_LABELS}
onChange={(v) => setCategory(v as ReportCategory)}
/>
</Box>
<Box direction="Column" gap="100">
+5 -23
View File
@@ -19,6 +19,7 @@ import {
} from 'folds';
import { Method } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ReportCategorySelect } from './ReportCategorySelect';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
@@ -137,31 +138,12 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
<Text as="label" htmlFor="report-user-category" size="L400">
Category
</Text>
<Box
as="select"
<ReportCategorySelect
id="report-user-category"
aria-label="Report category"
value={category}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setCategory(e.target.value as ReportCategory)
}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
color: color.Surface.OnContainer,
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
}}
>
{(Object.keys(CATEGORY_LABELS) as ReportCategory[]).map((key) => (
<option key={key} value={key}>
{CATEGORY_LABELS[key]}
</option>
))}
</Box>
labels={CATEGORY_LABELS}
onChange={(v) => setCategory(v as ReportCategory)}
/>
</Box>
<Box direction="Column" gap="100">