d0715774a8
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>
261 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
}
|