Files
cinny/src/app/features/room/PollCreator.tsx
T
jared d0715774a8 fix(ui): ScheduleMessageModal + PollCreator use folds Dialog shell (N15, N29)
Both rendered as <Box as="form" role="dialog"> with manually assembled
background/borderRadius(R400)/boxShadow. Switch to <Dialog as="form"
variant="Surface"> so the surface comes from the design system (R300 radius),
matching the other message-action dialogs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:32:16 -04:00

261 lines
8.4 KiB
TypeScript

import React, { FormEventHandler, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
Icons,
Input,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
color,
config,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
interface PollCreatorProps {
roomId: string;
room: Room;
onClose: () => void;
}
export function PollCreator({ roomId, onClose }: PollCreatorProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(440);
const [question, setQuestion] = useState('');
const [options, setOptions] = useState<string[]>(['', '']);
const [isMultiple, setIsMultiple] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleOptionChange = (index: number, value: string) => {
setOptions((prev) => {
const next = [...prev];
next[index] = value;
return next;
});
};
const handleAddOption = () => {
if (options.length >= 10) return;
setOptions((prev) => [...prev, '']);
};
const handleRemoveOption = (index: number) => {
if (options.length <= 2) return;
setOptions((prev) => prev.filter((_, i) => i !== index));
};
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault();
if (submitting) return;
const trimmedQuestion = question.trim();
if (!trimmedQuestion) {
setError('Please enter a question.');
return;
}
const filledOptions = options.map((o) => o.trim()).filter((o) => o.length > 0);
if (filledOptions.length < 2) {
setError('Please provide at least 2 answer options.');
return;
}
setError(null);
setSubmitting(true);
try {
await mx.sendEvent(roomId, 'm.poll.start' as any, {
'm.poll': {
question: { 'm.text': trimmedQuestion },
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
max_selections: isMultiple ? filledOptions.length : 1,
kind: 'm.poll.undisclosed',
},
body: trimmedQuestion,
msgtype: 'm.text',
});
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send poll.');
setSubmitting(false);
}
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
as="form"
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="poll-creator-title"
onSubmit={handleSubmit}
style={modalStyle}
>
{/* Header */}
<Header
variant="Surface"
size="500"
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Box grow="Yes">
<Text id="poll-creator-title" size="H4">
Create Poll
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
{/* Body */}
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{/* Question */}
<Box direction="Column" gap="100">
<Text as="label" htmlFor="poll-question" size="L400">
Question
</Text>
<Input
id="poll-question"
variant="Background"
placeholder="Ask a question…"
value={question}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuestion(e.target.value)}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
</Box>
{/* Options */}
<Box direction="Column" gap="200">
<Text size="L400">Options</Text>
{options.map((opt, index) => (
// eslint-disable-next-line react/no-array-index-key
<Box key={index} alignItems="Center" gap="200">
<Input
style={{ flex: 1 }}
variant="Background"
placeholder={`Option ${index + 1}`}
value={opt}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleOptionChange(index, e.target.value)
}
aria-label={`Option ${index + 1}`}
/>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
type="button"
onClick={() => handleRemoveOption(index)}
disabled={options.length <= 2}
aria-label={`Remove option ${index + 1}`}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
</Box>
))}
{options.length < 10 && (
<Box>
<Button
type="button"
size="300"
variant="Secondary"
fill="None"
radii="300"
before={<Icon src={Icons.Plus} size="100" />}
onClick={handleAddOption}
>
<Text size="B300">Add Option</Text>
</Button>
</Box>
)}
</Box>
{/* Selection type */}
<Box direction="Column" gap="100">
<Text size="L400">Selection Type</Text>
<Box gap="200">
{(['single', 'multiple'] as const).map((type) => {
const active = type === 'multiple' ? isMultiple : !isMultiple;
return (
<Button
key={type}
type="button"
size="300"
variant="Primary"
fill={active ? 'Solid' : 'None'}
radii="300"
onClick={() => setIsMultiple(type === 'multiple')}
>
<Text size="B300">
{type === 'single' ? 'Single choice' : 'Multiple choice'}
</Text>
</Button>
);
})}
</Box>
</Box>
{/* Error */}
{error && (
<Text size="T300" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
{/* Footer actions */}
<Box gap="200" justifyContent="End">
<Button
type="button"
size="400"
variant="Secondary"
fill="None"
radii="300"
onClick={onClose}
>
<Text size="B400">Cancel</Text>
</Button>
<Button
type="submit"
size="400"
variant="Primary"
fill="Solid"
radii="300"
aria-disabled={submitting}
before={
submitting ? <Spinner variant="Primary" fill="Solid" size="200" /> : undefined
}
>
<Text size="B400">{submitting ? 'Creating…' : 'Create Poll'}</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}