fix(ui): report category dropdown uses folds menu, not native select (N56)
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:
+1
-2
@@ -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`**
|
**N56 — Report Modal Category Dropdown: Native `<select>` Instead of folds `Chip`+`PopOut`+`Menu`**
|
||||||
|
|
||||||
- **File:** `src/app/features/room/ReportRoomModal.tsx` lines 138–163; `src/app/features/room/ReportUserModal.tsx` lines 144–169
|
- **File:** `src/app/features/room/ReportRoomModal.tsx` lines 138–163; `src/app/features/room/ReportUserModal.tsx` lines 144–169
|
||||||
- **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 63–114).
|
- **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 63–114).
|
||||||
- **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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { ReportCategorySelect } from './ReportCategorySelect';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
@@ -131,31 +132,12 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
|
|||||||
<Text as="label" htmlFor="report-category" size="L400">
|
<Text as="label" htmlFor="report-category" size="L400">
|
||||||
Category
|
Category
|
||||||
</Text>
|
</Text>
|
||||||
<Box
|
<ReportCategorySelect
|
||||||
as="select"
|
|
||||||
id="report-category"
|
id="report-category"
|
||||||
aria-label="Report category"
|
|
||||||
value={category}
|
value={category}
|
||||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
labels={CATEGORY_LABELS}
|
||||||
setCategory(e.target.value as ReportCategory)
|
onChange={(v) => setCategory(v 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>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { Method } from 'matrix-js-sdk';
|
import { Method } from 'matrix-js-sdk';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { ReportCategorySelect } from './ReportCategorySelect';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
@@ -137,31 +138,12 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
|
|||||||
<Text as="label" htmlFor="report-user-category" size="L400">
|
<Text as="label" htmlFor="report-user-category" size="L400">
|
||||||
Category
|
Category
|
||||||
</Text>
|
</Text>
|
||||||
<Box
|
<ReportCategorySelect
|
||||||
as="select"
|
|
||||||
id="report-user-category"
|
id="report-user-category"
|
||||||
aria-label="Report category"
|
|
||||||
value={category}
|
value={category}
|
||||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
labels={CATEGORY_LABELS}
|
||||||
setCategory(e.target.value as ReportCategory)
|
onChange={(v) => setCategory(v 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>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
|
|||||||
Reference in New Issue
Block a user