sforkowany z mirror/soapbox
Improve design of the Poll Form
rodzic
354159e1fa
commit
7782c96ba4
|
@ -20,7 +20,7 @@ export type AutoSuggestion = string | Emoji;
|
|||
const textAtCursorMatchesToken = (str: string, caretPosition: number, searchTokens: string[]): CursorMatch => {
|
||||
let word: string;
|
||||
|
||||
const left: number = str.slice(0, caretPosition).search(/\S+$/);
|
||||
const left: number = str.slice(0, caretPosition).search(/\S+$/);
|
||||
const right: number = str.slice(caretPosition).search(/\s/);
|
||||
|
||||
if (right < 0) {
|
||||
|
@ -201,13 +201,13 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
|
||||
if (typeof suggestion === 'object') {
|
||||
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||
key = suggestion.id;
|
||||
key = suggestion.id;
|
||||
} else if (suggestion[0] === '#') {
|
||||
inner = suggestion;
|
||||
key = suggestion;
|
||||
key = suggestion;
|
||||
} else {
|
||||
inner = <AutosuggestAccount id={suggestion} />;
|
||||
key = suggestion;
|
||||
key = suggestion;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -279,13 +279,13 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className='relative w-full'>
|
||||
<label className='sr-only'>{placeholder}</label>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
className={classNames({
|
||||
'block w-full sm:text-sm dark:bg-slate-800 dark:text-white dark:placeholder:text-gray-500 focus:ring-indigo-500 focus:border-indigo-500': true,
|
||||
'block w-full rounded-md sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-gray-500 focus:ring-primary-500 focus:border-primary-500': true,
|
||||
}, className)}
|
||||
ref={this.setInput}
|
||||
disabled={disabled}
|
||||
|
|
|
@ -64,7 +64,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
|||
type={revealed ? 'text' : type}
|
||||
ref={ref}
|
||||
className={classNames({
|
||||
'dark:bg-slate-800 dark:text-white block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500':
|
||||
'dark:bg-slate-800 dark:text-white block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md focus:ring-primary-500 focus:border-primary-500':
|
||||
true,
|
||||
'pr-7': isPassword,
|
||||
'text-red-600 border-red-600': hasError,
|
||||
|
|
|
@ -1,24 +1,23 @@
|
|||
'use strict';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import AutosuggestInput from 'soapbox/components/autosuggest_input';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import { Button, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import DurationSelector from './polls/duration-selector';
|
||||
|
||||
import type { AutoSuggestion } from 'soapbox/components/autosuggest_input';
|
||||
|
||||
const messages = defineMessages({
|
||||
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
|
||||
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
|
||||
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
|
||||
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Answer #{number}' },
|
||||
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add an answer' },
|
||||
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this answer' },
|
||||
poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
|
||||
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
|
||||
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' },
|
||||
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple answers' },
|
||||
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single answer' },
|
||||
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
|
||||
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
|
||||
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
|
||||
|
@ -26,7 +25,6 @@ const messages = defineMessages({
|
|||
|
||||
interface IOption {
|
||||
index: number
|
||||
isPollMultiple?: boolean
|
||||
maxChars: number
|
||||
numOptions: number
|
||||
onChange(index: number, value: string): void
|
||||
|
@ -35,7 +33,6 @@ interface IOption {
|
|||
onRemove(index: number): void
|
||||
onRemovePoll(): void
|
||||
onSuggestionSelected(tokenStart: number, token: string, value: string, key: (string | number)[]): void
|
||||
onToggleMultiple(): void
|
||||
suggestions?: any // list
|
||||
title: string
|
||||
}
|
||||
|
@ -43,7 +40,6 @@ interface IOption {
|
|||
const Option = (props: IOption) => {
|
||||
const {
|
||||
index,
|
||||
isPollMultiple,
|
||||
maxChars,
|
||||
numOptions,
|
||||
onChange,
|
||||
|
@ -51,7 +47,6 @@ const Option = (props: IOption) => {
|
|||
onFetchSuggestions,
|
||||
onRemove,
|
||||
onRemovePoll,
|
||||
onToggleMultiple,
|
||||
suggestions,
|
||||
title,
|
||||
} = props;
|
||||
|
@ -68,20 +63,20 @@ const Option = (props: IOption) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleToggleMultiple = (event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// const handleToggleMultiple = (event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => {
|
||||
// event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
|
||||
onToggleMultiple();
|
||||
};
|
||||
// onToggleMultiple();
|
||||
// };
|
||||
|
||||
const onSuggestionsClearRequested = () => onClearSuggestions();
|
||||
|
||||
const handleCheckboxKeypress = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
handleToggleMultiple(event);
|
||||
}
|
||||
};
|
||||
// const handleCheckboxKeypress = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
// if (event.key === 'Enter' || event.key === ' ') {
|
||||
// handleToggleMultiple(event);
|
||||
// }
|
||||
// };
|
||||
|
||||
const onSuggestionsFetchRequested = (token: string) => onFetchSuggestions(token);
|
||||
|
||||
|
@ -92,17 +87,11 @@ const Option = (props: IOption) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<li>
|
||||
<label className='poll__text editable'>
|
||||
<span
|
||||
className={classNames('poll__input', { checkbox: isPollMultiple })}
|
||||
onClick={handleToggleMultiple}
|
||||
onKeyPress={handleCheckboxKeypress}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
title={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)}
|
||||
aria-label={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)}
|
||||
/>
|
||||
<HStack alignItems='center' justifyContent='between' space={4}>
|
||||
<HStack alignItems='center' space={2} grow>
|
||||
<div className='w-6'>
|
||||
<Text weight='bold'>{index + 1}</Text>
|
||||
</div>
|
||||
|
||||
<AutosuggestInput
|
||||
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
|
||||
|
@ -116,16 +105,14 @@ const Option = (props: IOption) => {
|
|||
searchTokens={[':']}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
</HStack>
|
||||
|
||||
<div className='poll__cancel'>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.remove_option)}
|
||||
src={require('@tabler/icons/icons/x.svg')}
|
||||
onClick={handleOptionRemove}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
{index > 1 && (
|
||||
<div>
|
||||
<Button theme='danger' size='sm' onClick={handleOptionRemove}>Delete</Button>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -156,27 +143,21 @@ const PollForm = (props: IPollForm) => {
|
|||
...filteredProps
|
||||
} = props;
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
const pollLimits = useAppSelector((state) => state.instance.getIn(['configuration', 'polls']) as any);
|
||||
const maxOptions = pollLimits.get('max_options');
|
||||
const maxOptionChars = pollLimits.get('max_characters_per_option');
|
||||
|
||||
const handleAddOption = () => onAddOption('');
|
||||
|
||||
const handleSelectDuration = (event: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
onChangeSettings(event.target.value, isMultiple);
|
||||
|
||||
const handleToggleMultiple = () => onChangeSettings(expiresIn, !isMultiple);
|
||||
|
||||
const handleSelectDuration = (value: number) => onChangeSettings(value, isMultiple);
|
||||
// const handleToggleMultiple = () => onChangeSettings(expiresIn, !isMultiple);
|
||||
|
||||
if (!options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='compose-form__poll-wrapper'>
|
||||
<ul>
|
||||
<Stack space={4}>
|
||||
<Stack space={2}>
|
||||
{options.map((title: string, i: number) => (
|
||||
<Option
|
||||
title={title}
|
||||
|
@ -184,34 +165,41 @@ const PollForm = (props: IPollForm) => {
|
|||
index={i}
|
||||
onChange={onChangeOption}
|
||||
onRemove={onRemoveOption}
|
||||
isPollMultiple={isMultiple}
|
||||
onToggleMultiple={handleToggleMultiple}
|
||||
maxChars={maxOptionChars}
|
||||
numOptions={options.size}
|
||||
{...filteredProps}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<HStack className='text-black dark:text-white' space={2}>
|
||||
{options.size < maxOptions && (
|
||||
<button className='button button-secondary' onClick={handleAddOption}>
|
||||
<Icon src={require('@tabler/icons/icons/plus.svg')} />
|
||||
<FormattedMessage {...messages.add_option} />
|
||||
</button>
|
||||
)}
|
||||
<HStack space={2}>
|
||||
<div className='w-6' />
|
||||
|
||||
<select value={expiresIn} onChange={handleSelectDuration}>
|
||||
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
|
||||
<option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
|
||||
<option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
|
||||
<option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
|
||||
<option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
|
||||
<option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
|
||||
<option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
|
||||
</select>
|
||||
</HStack>
|
||||
</div>
|
||||
{options.size < maxOptions && (
|
||||
<Button
|
||||
theme='secondary'
|
||||
icon={require('@tabler/icons/icons/plus.svg')}
|
||||
onClick={handleAddOption}
|
||||
size='sm'
|
||||
>
|
||||
<FormattedMessage {...messages.add_option} />
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
|
||||
|
||||
{/* Duration */}
|
||||
<Stack space={2}>
|
||||
<Text size='lg' weight='medium'>Duration</Text>
|
||||
|
||||
<DurationSelector onDurationChange={handleSelectDuration} />
|
||||
</Stack>
|
||||
|
||||
{/* Remove Poll */}
|
||||
<div className='text-center'>
|
||||
<Button theme='danger' onClick={props.onRemovePoll}>Remove Poll</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '../../../../../jest/test-helpers';
|
||||
import DurationSelector from '../duration-selector';
|
||||
|
||||
describe('<DurationSelector />', () => {
|
||||
it('defaults to 2 days', () => {
|
||||
const handler = jest.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
expect(screen.getByTestId('duration-selector-days')).toHaveValue('2');
|
||||
expect(screen.getByTestId('duration-selector-hours')).toHaveValue('0');
|
||||
expect(screen.getByTestId('duration-selector-minutes')).toHaveValue('0');
|
||||
});
|
||||
|
||||
describe('when changing the day', () => {
|
||||
it('calls the "onDurationChange" callback', async() => {
|
||||
const handler = jest.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-days'),
|
||||
screen.getByRole('option', { name: '1 day' }),
|
||||
);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days
|
||||
expect(handler.mock.calls[1][0]).toEqual(86400); // 1 day
|
||||
});
|
||||
|
||||
it('should disable the hour/minute select if 7 days selected', async() => {
|
||||
const handler = jest.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
expect(screen.getByTestId('duration-selector-hours')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('duration-selector-minutes')).not.toBeDisabled();
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-days'),
|
||||
screen.getByRole('option', { name: '7 days' }),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('duration-selector-hours')).toBeDisabled();
|
||||
expect(screen.getByTestId('duration-selector-minutes')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changing the hour', () => {
|
||||
it('calls the "onDurationChange" callback', async() => {
|
||||
const handler = jest.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-hours'),
|
||||
screen.getByRole('option', { name: '1 hour' }),
|
||||
);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days
|
||||
expect(handler.mock.calls[1][0]).toEqual(176400); // 2 days, 1 hour
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changing the minute', () => {
|
||||
it('calls the "onDurationChange" callback', async() => {
|
||||
const handler = jest.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-minutes'),
|
||||
screen.getByRole('option', { name: '15 minutes' }),
|
||||
);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days
|
||||
expect(handler.mock.calls[1][0]).toEqual(173700); // 2 days, 1 minute
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,93 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Select } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
|
||||
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
|
||||
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
|
||||
});
|
||||
|
||||
interface IDurationSelector {
|
||||
onDurationChange(expiresIn: number): void
|
||||
}
|
||||
|
||||
const DurationSelector = ({ onDurationChange }: IDurationSelector) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [days, setDays] = useState<number>(2);
|
||||
const [hours, setHours] = useState<number>(0);
|
||||
const [minutes, setMinutes] = useState<number>(0);
|
||||
|
||||
const value = useMemo(() => {
|
||||
const now: any = new Date();
|
||||
const future: any = new Date();
|
||||
now.setDate(now.getDate() + days);
|
||||
now.setMinutes(now.getMinutes() + minutes);
|
||||
now.setHours(now.getHours() + hours);
|
||||
|
||||
return (now - future) / 1000;
|
||||
}, [days, hours, minutes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (days === 7) {
|
||||
setHours(0);
|
||||
setMinutes(0);
|
||||
}
|
||||
}, [days]);
|
||||
|
||||
useEffect(() => {
|
||||
onDurationChange(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-y-2 gap-x-2 sm:grid-cols-3'>
|
||||
<div className='sm:col-span-1'>
|
||||
<Select
|
||||
value={days}
|
||||
onChange={(event) => setDays(Number(event.target.value))}
|
||||
data-testid='duration-selector-days'
|
||||
>
|
||||
{[...Array(8).fill(undefined)].map((_, number) => (
|
||||
<option value={number} key={number}>
|
||||
{intl.formatMessage(messages.days, { number })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='sm:col-span-1'>
|
||||
<Select
|
||||
value={hours}
|
||||
onChange={(event) => setHours(Number(event.target.value))}
|
||||
disabled={days === 7}
|
||||
data-testid='duration-selector-hours'
|
||||
>
|
||||
{[...Array(24).fill(undefined)].map((_, number) => (
|
||||
<option value={number} key={number}>
|
||||
{intl.formatMessage(messages.hours, { number })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='sm:col-span-1'>
|
||||
<Select
|
||||
value={minutes}
|
||||
onChange={(event) => setMinutes(Number(event.target.value))}
|
||||
disabled={days === 7}
|
||||
data-testid='duration-selector-minutes'
|
||||
>
|
||||
{[0, 15, 30, 45].map((number) => (
|
||||
<option value={number} key={number}>
|
||||
{intl.formatMessage(messages.minutes, { number })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DurationSelector;
|
|
@ -271,12 +271,12 @@
|
|||
"compose_form.markdown.unmarked": "Post markdown disabled",
|
||||
"compose_form.message": "Message",
|
||||
"compose_form.placeholder": "What's on your mind?",
|
||||
"compose_form.poll.add_option": "Add a choice",
|
||||
"compose_form.poll.add_option": "Add an answer",
|
||||
"compose_form.poll.duration": "Poll duration",
|
||||
"compose_form.poll.option_placeholder": "Choice {number}",
|
||||
"compose_form.poll.remove_option": "Remove this choice",
|
||||
"compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
|
||||
"compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
|
||||
"compose_form.poll.option_placeholder": "Answer #{number}",
|
||||
"compose_form.poll.remove_option": "Remove this answer",
|
||||
"compose_form.poll.switch_to_multiple": "Change poll to allow multiple answers",
|
||||
"compose_form.poll.switch_to_single": "Change poll to allow for a single answer",
|
||||
"compose_form.publish": "Post",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.schedule": "Schedule",
|
||||
|
|
|
@ -118,49 +118,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.compose-form__poll-wrapper {
|
||||
border-top: 1px solid var(--foreground-color);
|
||||
|
||||
ul {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.button.button-secondary {
|
||||
@apply h-auto py-1.5 px-2.5 text-primary-600 dark:text-primary-400 border-primary-600;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.poll__text {
|
||||
flex: 0 0 auto;
|
||||
width: calc(100% - (23px + 6px));
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
@apply border border-solid border-primary-600 bg-white dark:bg-slate-800;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
outline: 0;
|
||||
font-family: inherit;
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: auto 16px;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.icon-button.disabled {
|
||||
color: var(--brand-color);
|
||||
}
|
||||
}
|
||||
|
||||
.muted .poll {
|
||||
color: var(--primary-text-color);
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue