mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-04-25 02:48:23 +01:00
Poll create modal
This commit is contained in:
393
ts/components/PollCreateModal.dom.tsx
Normal file
393
ts/components/PollCreateModal.dom.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { v4 as generateUuid } from 'uuid';
|
||||
import { tw } from '../axo/tw.dom.js';
|
||||
import type { LocalizerType } from '../types/Util.std.js';
|
||||
import { Modal } from './Modal.dom.js';
|
||||
import { AutoSizeTextArea } from './AutoSizeTextArea.dom.js';
|
||||
import { AxoButton } from '../axo/AxoButton.dom.js';
|
||||
import { AxoSwitch } from '../axo/AxoSwitch.dom.js';
|
||||
import { Toast } from './Toast.dom.js';
|
||||
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.js';
|
||||
import { FunEmojiPickerButton } from './fun/FunButton.dom.js';
|
||||
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.js';
|
||||
import { getEmojiVariantByKey } from './fun/data/emojis.std.js';
|
||||
import { strictAssert } from '../util/assert.std.js';
|
||||
import {
|
||||
type PollCreateType,
|
||||
POLL_QUESTION_MAX_LENGTH,
|
||||
POLL_OPTIONS_MIN_COUNT,
|
||||
POLL_OPTIONS_MAX_COUNT,
|
||||
} from '../types/Polls.dom.js';
|
||||
import { count as countGraphemes } from '../util/grapheme.std.js';
|
||||
|
||||
type PollOption = {
|
||||
id: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type PollCreateModalProps = {
|
||||
i18n: LocalizerType;
|
||||
onClose: () => void;
|
||||
onSendPoll: (poll: PollCreateType) => void;
|
||||
};
|
||||
|
||||
export function PollCreateModal({
|
||||
i18n,
|
||||
onClose,
|
||||
onSendPoll,
|
||||
}: PollCreateModalProps): JSX.Element {
|
||||
const [question, setQuestion] = useState('');
|
||||
const [options, setOptions] = useState<Array<PollOption>>([
|
||||
{ id: generateUuid(), value: '' },
|
||||
{ id: generateUuid(), value: '' },
|
||||
]);
|
||||
const [allowMultiple, setAllowMultiple] = useState(false);
|
||||
const [emojiPickerOpenForOption, setEmojiPickerOpenForOption] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<{
|
||||
question: boolean;
|
||||
options: boolean;
|
||||
}>({ question: false, options: false });
|
||||
const [validationErrorMessages, setValidationErrorMessages] =
|
||||
useState<Array<string> | null>(null);
|
||||
|
||||
const questionInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const optionRefsMap = useRef<Map<string, HTMLTextAreaElement | null>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
const computeOptionsAfterChange = useCallback(
|
||||
(
|
||||
updatedOptions: Array<PollOption>,
|
||||
changedOptionId: string
|
||||
): { options: Array<PollOption>; removedIndex?: number } => {
|
||||
const resultOptions = [...updatedOptions];
|
||||
const changedIndex = resultOptions.findIndex(
|
||||
opt => opt.id === changedOptionId
|
||||
);
|
||||
const isLastOption = changedIndex === resultOptions.length - 1;
|
||||
const isSecondToLast = changedIndex === resultOptions.length - 2;
|
||||
const changedOption = resultOptions[changedIndex];
|
||||
const hasText = changedOption?.value.trim().length > 0;
|
||||
const canAddMore = resultOptions.length < POLL_OPTIONS_MAX_COUNT;
|
||||
const canRemove = resultOptions.length > POLL_OPTIONS_MIN_COUNT;
|
||||
let removedIndex: number | undefined;
|
||||
|
||||
// Add new empty option when typing in the last option
|
||||
if (isLastOption && hasText && canAddMore) {
|
||||
resultOptions.push({ id: generateUuid(), value: '' });
|
||||
}
|
||||
|
||||
// Remove the last option if second-to-last becomes empty and last is also empty
|
||||
if (isSecondToLast && !hasText && canRemove) {
|
||||
const lastOption = resultOptions[resultOptions.length - 1];
|
||||
const lastOptionEmpty = !lastOption?.value.trim();
|
||||
if (lastOptionEmpty) {
|
||||
resultOptions.pop();
|
||||
removedIndex = resultOptions.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove middle empty options
|
||||
if (!isLastOption && !hasText && canRemove) {
|
||||
resultOptions.splice(changedIndex, 1);
|
||||
removedIndex = changedIndex;
|
||||
|
||||
// Ensure there's always an empty option at the end
|
||||
const lastOption = resultOptions[resultOptions.length - 1];
|
||||
const lastOptionEmpty = !lastOption || !lastOption.value.trim();
|
||||
if (!lastOptionEmpty && resultOptions.length < POLL_OPTIONS_MAX_COUNT) {
|
||||
resultOptions.push({ id: generateUuid(), value: '' });
|
||||
}
|
||||
}
|
||||
|
||||
return { options: resultOptions, removedIndex };
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleQuestionChange = useCallback(
|
||||
(value: string) => {
|
||||
setQuestion(value);
|
||||
if (validationErrors.question || validationErrors.options) {
|
||||
setValidationErrors({ question: false, options: false });
|
||||
}
|
||||
},
|
||||
[validationErrors]
|
||||
);
|
||||
|
||||
const handleOptionChange = useCallback(
|
||||
(id: string, value: string) => {
|
||||
const updatedOptions = options.map(opt =>
|
||||
opt.id === id ? { ...opt, value } : opt
|
||||
);
|
||||
const result = computeOptionsAfterChange(updatedOptions, id);
|
||||
|
||||
flushSync(() => {
|
||||
setOptions(result.options);
|
||||
});
|
||||
|
||||
// Handle focus management if an option was removed
|
||||
if (result.removedIndex !== undefined) {
|
||||
const focusIndex = Math.min(
|
||||
result.removedIndex,
|
||||
result.options.length - 1
|
||||
);
|
||||
const targetOption = result.options[focusIndex];
|
||||
if (targetOption) {
|
||||
optionRefsMap.current.get(targetOption.id)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.question || validationErrors.options) {
|
||||
setValidationErrors({ question: false, options: false });
|
||||
}
|
||||
},
|
||||
[computeOptionsAfterChange, validationErrors, options]
|
||||
);
|
||||
|
||||
const handleEnterKey = useCallback(
|
||||
(event: React.KeyboardEvent, currentIndex: number) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nextOption = options[currentIndex + 1];
|
||||
if (nextOption) {
|
||||
optionRefsMap.current.get(nextOption.id)?.focus();
|
||||
}
|
||||
},
|
||||
[options]
|
||||
);
|
||||
|
||||
const handleSelectEmoji = useCallback(
|
||||
(optionId: string, emojiSelection: FunEmojiSelection) => {
|
||||
const inputEl = optionRefsMap.current.get(optionId);
|
||||
strictAssert(inputEl, 'Missing input ref for option');
|
||||
|
||||
const { selectionStart, selectionEnd } = inputEl;
|
||||
const variant = getEmojiVariantByKey(emojiSelection.variantKey);
|
||||
const emoji = variant.value;
|
||||
|
||||
const updatedOptions = options.map(opt => {
|
||||
if (opt.id !== optionId) {
|
||||
return opt;
|
||||
}
|
||||
|
||||
let newValue: string;
|
||||
if (selectionStart == null || selectionEnd == null) {
|
||||
newValue = `${opt.value}${emoji}`;
|
||||
} else {
|
||||
const before = opt.value.slice(0, selectionStart);
|
||||
const after = opt.value.slice(selectionEnd);
|
||||
newValue = `${before}${emoji}${after}`;
|
||||
}
|
||||
|
||||
// Don't insert if it would exceed the max grapheme length
|
||||
if (countGraphemes(newValue) > POLL_QUESTION_MAX_LENGTH) {
|
||||
return opt; // Return unchanged
|
||||
}
|
||||
|
||||
return { ...opt, value: newValue };
|
||||
});
|
||||
|
||||
const result = computeOptionsAfterChange(updatedOptions, optionId);
|
||||
setOptions(result.options);
|
||||
},
|
||||
[computeOptionsAfterChange, options]
|
||||
);
|
||||
|
||||
const allowSend = useMemo(() => {
|
||||
if (question.trim()) {
|
||||
return true;
|
||||
}
|
||||
return options.some(opt => opt.value.trim());
|
||||
}, [question, options]);
|
||||
|
||||
const validatePoll = useCallback((): {
|
||||
errors: Array<string>;
|
||||
hasQuestionError: boolean;
|
||||
hasOptionsError: boolean;
|
||||
} => {
|
||||
const errors: Array<string> = [];
|
||||
const hasQuestionError = !question.trim();
|
||||
const nonEmptyOptions = options.filter(opt => opt.value.trim());
|
||||
const hasOptionsError = nonEmptyOptions.length < POLL_OPTIONS_MIN_COUNT;
|
||||
|
||||
if (hasQuestionError) {
|
||||
errors.push(i18n('icu:PollCreateModal__Error--RequiresQuestion'));
|
||||
}
|
||||
|
||||
if (hasOptionsError) {
|
||||
errors.push(i18n('icu:PollCreateModal__Error--RequiresTwoOptions'));
|
||||
}
|
||||
|
||||
return { errors, hasQuestionError, hasOptionsError };
|
||||
}, [question, options, i18n]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const validation = validatePoll();
|
||||
if (validation.errors.length > 0) {
|
||||
// Set validation error state for aria-invalid
|
||||
setValidationErrors({
|
||||
question: validation.hasQuestionError,
|
||||
options: validation.hasOptionsError,
|
||||
});
|
||||
|
||||
// Show local toast with errors
|
||||
setValidationErrorMessages(validation.errors);
|
||||
|
||||
// Focus the first invalid field
|
||||
if (validation.hasQuestionError) {
|
||||
questionInputRef.current?.focus();
|
||||
} else if (validation.hasOptionsError) {
|
||||
// Find first empty option or just focus the first option
|
||||
const firstEmptyOption = options.find(opt => !opt.value.trim());
|
||||
const targetOptionId = firstEmptyOption?.id ?? options[0]?.id;
|
||||
if (targetOptionId) {
|
||||
optionRefsMap.current.get(targetOptionId)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nonEmptyOptions = options
|
||||
.map(opt => opt.value.trim())
|
||||
.filter(value => value.length > 0);
|
||||
|
||||
const poll: PollCreateType = {
|
||||
question: question.trim(),
|
||||
options: nonEmptyOptions,
|
||||
allowMultiple,
|
||||
};
|
||||
|
||||
onSendPoll(poll);
|
||||
}, [validatePoll, question, options, allowMultiple, onSendPoll]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
modalName="PollCreateModal"
|
||||
i18n={i18n}
|
||||
title={i18n('icu:PollCreateModal__title')}
|
||||
hasXButton
|
||||
onClose={onClose}
|
||||
noMouseClose
|
||||
>
|
||||
{/* Visually hidden error messages for screen readers */}
|
||||
<div id="poll-question-error" className={tw('sr-only')}>
|
||||
{i18n('icu:PollCreateModal__Error--RequiresQuestion')}
|
||||
</div>
|
||||
<div id="poll-options-error" className={tw('sr-only')}>
|
||||
{i18n('icu:PollCreateModal__Error--RequiresTwoOptions')}
|
||||
</div>
|
||||
|
||||
<div className={tw('flex flex-col')}>
|
||||
<div className={tw('ms-2 mt-4')}>
|
||||
<div className={tw('type-body-medium font-semibold')}>
|
||||
{i18n('icu:PollCreateModal__questionLabel')}
|
||||
</div>
|
||||
|
||||
<div className={tw('mt-5')}>
|
||||
<AutoSizeTextArea
|
||||
ref={questionInputRef}
|
||||
i18n={i18n}
|
||||
moduleClassName="PollCreateModalInput"
|
||||
value={question}
|
||||
onChange={handleQuestionChange}
|
||||
placeholder={i18n('icu:PollCreateModal__questionPlaceholder')}
|
||||
maxLengthCount={POLL_QUESTION_MAX_LENGTH}
|
||||
whenToShowRemainingCount={POLL_QUESTION_MAX_LENGTH - 30}
|
||||
aria-invalid={validationErrors.question || undefined}
|
||||
aria-errormessage={
|
||||
validationErrors.question ? 'poll-question-error' : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={tw('mt-5 type-body-medium font-semibold')}>
|
||||
{i18n('icu:PollCreateModal__optionsLabel')}
|
||||
</div>
|
||||
|
||||
<div className={tw('mt-5 flex flex-col gap-4')}>
|
||||
{options.map((option, index) => (
|
||||
<div key={option.id}>
|
||||
<AutoSizeTextArea
|
||||
ref={el => optionRefsMap.current.set(option.id, el)}
|
||||
i18n={i18n}
|
||||
moduleClassName="PollCreateModalInput"
|
||||
value={option.value}
|
||||
onChange={value => handleOptionChange(option.id, value)}
|
||||
onEnter={e => handleEnterKey(e, index)}
|
||||
placeholder={i18n('icu:PollCreateModal__optionPlaceholder', {
|
||||
number: String(index + 1),
|
||||
})}
|
||||
maxLengthCount={POLL_QUESTION_MAX_LENGTH}
|
||||
whenToShowRemainingCount={POLL_QUESTION_MAX_LENGTH - 30}
|
||||
aria-invalid={validationErrors.options || undefined}
|
||||
aria-errormessage={
|
||||
validationErrors.options ? 'poll-options-error' : undefined
|
||||
}
|
||||
>
|
||||
<FunEmojiPicker
|
||||
open={emojiPickerOpenForOption === option.id}
|
||||
onOpenChange={open => {
|
||||
setEmojiPickerOpenForOption(open ? option.id : null);
|
||||
}}
|
||||
onSelectEmoji={emojiSelection =>
|
||||
handleSelectEmoji(option.id, emojiSelection)
|
||||
}
|
||||
closeOnSelect
|
||||
>
|
||||
<FunEmojiPickerButton i18n={i18n} />
|
||||
</FunEmojiPicker>
|
||||
</AutoSizeTextArea>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={tw('mt-8 h-[0.5px] bg-border-primary')} />
|
||||
|
||||
<label className={tw('mt-6 flex items-center gap-3')}>
|
||||
<span className={tw('grow type-body-large')}>
|
||||
{i18n('icu:PollCreateModal__allowMultipleVotes')}
|
||||
</span>
|
||||
<AxoSwitch.Root
|
||||
checked={allowMultiple}
|
||||
onCheckedChange={setAllowMultiple}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={tw('mt-3 flex min-h-[26px] items-center justify-center')}
|
||||
>
|
||||
{validationErrorMessages && (
|
||||
<div aria-hidden="true">
|
||||
<Toast onClose={() => setValidationErrorMessages(null)}>
|
||||
{validationErrorMessages[0]}
|
||||
</Toast>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={tw('mt-3 flex justify-end gap-3')}>
|
||||
<AxoButton.Root variant="secondary" size="large" onClick={onClose}>
|
||||
{i18n('icu:cancel')}
|
||||
</AxoButton.Root>
|
||||
<AxoButton.Root
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={handleSend}
|
||||
disabled={!allowSend}
|
||||
>
|
||||
{i18n('icu:PollCreateModal__sendButton')}
|
||||
</AxoButton.Root>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user