Redesign Poll within Status

environments/review-poll-impro-o4k7hi/deployments/284
Justin 2022-06-14 16:14:14 -04:00
rodzic f34a5b81f8
commit fb9796b0c4
7 zmienionych plików z 122 dodań i 78 usunięć

Wyświetl plik

@ -196,7 +196,7 @@
"expired": false, "expired": false,
"expires_at": "2022-03-12T01:33:18.000Z", "expires_at": "2022-03-12T01:33:18.000Z",
"id": "AHHue67gF2JDqCQGhc", "id": "AHHue67gF2JDqCQGhc",
"multiple": false, "multiple": true,
"options": [ "options": [
{ {
"title": "Regular emoji 😍 ", "title": "Regular emoji 😍 ",

Wyświetl plik

@ -6,8 +6,7 @@ import { useDispatch } from 'react-redux';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { vote, fetchPoll } from 'soapbox/actions/polls'; import { vote, fetchPoll } from 'soapbox/actions/polls';
import Icon from 'soapbox/components/icon'; import { Text, Button, Icon, Stack, HStack } from 'soapbox/components/ui';
import { Text, Button, Stack, HStack } from 'soapbox/components/ui';
import Motion from 'soapbox/features/ui/util/optional_motion'; import Motion from 'soapbox/features/ui/util/optional_motion';
import { useAppSelector } from 'soapbox/hooks'; import { useAppSelector } from 'soapbox/hooks';
@ -21,10 +20,10 @@ const messages = defineMessages({
votes: { id: 'poll.votes', defaultMessage: '{votes, plural, one {# vote} other {# votes}}' }, votes: { id: 'poll.votes', defaultMessage: '{votes, plural, one {# vote} other {# votes}}' },
}); });
const PollPercentageBar: React.FC<{percent: number, leading: boolean}> = ({ percent, leading }): JSX.Element => { const PollPercentageBar: React.FC<{ percent: number, leading: boolean }> = ({ percent, leading }): JSX.Element => {
return ( return (
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}> <Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
{({ width }) =>( {({ width }) => (
<span <span
className={classNames('absolute inset-0 h-full inline-block rounded bg-gray-300 dark:bg-slate-900', { className={classNames('absolute inset-0 h-full inline-block rounded bg-gray-300 dark:bg-slate-900', {
'bg-primary-300 dark:bg-primary-400': leading, 'bg-primary-300 dark:bg-primary-400': leading,
@ -59,7 +58,12 @@ const PollOptionText: React.FC<IPollOptionText> = ({ poll, option, index, active
return ( return (
<label <label
className={classNames('relative', { 'cursor-pointer': !showResults })} className={classNames({
'relative flex rounded-full border border-solid bg-white p-2 hover:bg-primary-50': true,
'border-primary-600 ring-1 ring-primary-600 bg-primary-50': active,
'border-primary-300': !active,
'cursor-pointer': !showResults,
})}
title={showResults ? message : undefined} title={showResults ? message : undefined}
> >
<input <input
@ -71,34 +75,44 @@ const PollOptionText: React.FC<IPollOptionText> = ({ poll, option, index, active
onChange={handleOptionChange} onChange={handleOptionChange}
/> />
<HStack alignItems='center' className='p-1 text-gray-900 dark:text-gray-300'> <div className='grid items-center w-full'>
{!showResults && ( <div className='col-start-1 row-start-1 justify-self-center ml-4 mr-6'>
<Text
theme='primary'
weight='semibold'
dangerouslySetInnerHTML={{ __html: option.title_emojified }}
/>
</div>
<div className='col-start-1 row-start-1 justify-self-end flex items-center'>
{showResults ? (
<HStack space={2} alignItems='center' className='mr-2.5'>
{voted ? (
<Icon src={require('@tabler/icons/icons/check.svg')} alt={intl.formatMessage(messages.voted)} />
) : (
<div className='svg-icon' />
)}
<span className='font-bold'>{Math.round(percent)}%</span>
</HStack>
) : (
<span <span
className={classNames('inline-block w-4 h-4 flex-none mr-2.5 border border-solid border-primary-600 rounded-full', { className={classNames('flex items-center justify-center w-6 h-6 flex-none border border-solid rounded-full', {
'bg-primary-600': active, 'bg-primary-600 border-primary-600': active,
'rounded': poll.multiple, 'border-primary-300 bg-white': !active,
})} })}
tabIndex={0} tabIndex={0}
role={poll.multiple ? 'checkbox' : 'radio'} role={poll.multiple ? 'checkbox' : 'radio'}
onKeyPress={handleOptionKeyPress} onKeyPress={handleOptionKeyPress}
aria-checked={active} aria-checked={active}
aria-label={option.title} aria-label={option.title}
/> >
{active && (
<Icon src={require('@tabler/icons/icons/check.svg')} className='text-white w-4 h-4' />
)} )}
</span>
{showResults && (
<HStack space={2} alignItems='center' className='mr-2.5'>
{voted ? (
<Icon src={require('@tabler/icons/icons/check.svg')} title={intl.formatMessage(messages.voted)} />
) : (
<div className='svg-icon' />
)} )}
<span className='font-bold'>{Math.round(percent)}%</span> </div>
</HStack> </div>
)}
<span dangerouslySetInnerHTML={{ __html: option.title_emojified }} />
</HStack>
</label> </label>
); );
}; };
@ -120,7 +134,7 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count); const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count);
return ( return (
<div className='relative mb-2.5' key={option.title}> <div className='relative' key={option.title}>
{showResults && ( {showResults && (
<PollPercentageBar percent={percent} leading={leading} /> <PollPercentageBar percent={percent} leading={leading} />
)} )}
@ -130,7 +144,7 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
); );
}; };
const RefreshButton: React.FC<{poll: PollEntity}> = ({ poll }): JSX.Element => { const RefreshButton: React.FC<{ poll: PollEntity }> = ({ poll }): JSX.Element => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const handleRefresh: React.EventHandler<React.MouseEvent> = (e) => { const handleRefresh: React.EventHandler<React.MouseEvent> = (e) => {
@ -148,12 +162,12 @@ const RefreshButton: React.FC<{poll: PollEntity}> = ({ poll }): JSX.Element => {
); );
}; };
const VoteButton: React.FC<{poll: PollEntity, selected: Selected}> = ({ poll, selected }): JSX.Element => { const VoteButton: React.FC<{ poll: PollEntity, selected: Selected }> = ({ poll, selected }): JSX.Element => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const handleVote = () => dispatch(vote(poll.id, Object.keys(selected))); const handleVote = () => dispatch(vote(poll.id, Object.keys(selected)));
return ( return (
<Button onClick={handleVote} theme='ghost'> <Button onClick={handleVote} theme='primary' block>
<FormattedMessage id='poll.vote' defaultMessage='Vote' /> <FormattedMessage id='poll.vote' defaultMessage='Vote' />
</Button> </Button>
); );
@ -167,18 +181,35 @@ interface IPollFooter {
const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX.Element => { const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX.Element => {
const intl = useIntl(); const intl = useIntl();
const timeRemaining = poll.expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.expires_at} futureDate />; const timeRemaining = poll.expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp weight='medium' timestamp={poll.expires_at} futureDate />;
return ( return (
<Stack space={2}> <Stack space={4}>
{!showResults && <div><VoteButton poll={poll} selected={selected} /></div>} {!showResults && <div><VoteButton poll={poll} selected={selected} /></div>}
<Text>
<HStack space={1.5} alignItems='center'>
{showResults && ( {showResults && (
<><RefreshButton poll={poll} /> · </> <>
<RefreshButton poll={poll} />
<Text theme='muted'>&middot;</Text>
</>
)} )}
<FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.votes_count }} />
{poll.expires_at && <span> · {timeRemaining}</span>} <Text theme='muted' weight='medium'>
<FormattedMessage
id='poll.total_votes'
defaultMessage='{count, plural, one {# vote} other {# votes}}'
values={{ count: poll.votes_count }}
/>
</Text> </Text>
{poll.expires_at && (
<>
<Text theme='muted'>&middot;</Text>
<Text weight='medium' theme='muted'>{timeRemaining}</Text>
</>
)}
</HStack>
</Stack> </Stack>
); );
}; };
@ -231,8 +262,8 @@ const Poll: React.FC<IPoll> = ({ id, status }): JSX.Element | null => {
return ( return (
<div onClick={e => e.stopPropagation()}> <div onClick={e => e.stopPropagation()}>
<Stack className={classNames('my-2', { voted: poll.voted })}> <Stack space={4} className={classNames('mt-4', { voted: poll.voted })}>
<Stack> <Stack space={2}>
{poll.options.map((option, i) => ( {poll.options.map((option, i) => (
<PollOption <PollOption
key={i} key={i}

Wyświetl plik

@ -179,7 +179,7 @@ class RelativeTimestamp extends React.Component {
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year); const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year);
return ( return (
<Text {...textProps} color='inherit' tag='time' title={intl.formatDate(date, dateFormatOptions)}> <Text {...textProps} theme='inherit' tag='time' title={intl.formatDate(date, dateFormatOptions)}>
{relativeTime} {relativeTime}
</Text> </Text>
); );

Wyświetl plik

@ -174,8 +174,8 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
const parentNode = target.parentNode as HTMLElement; const parentNode = target.parentNode as HTMLElement;
const [ startX, startY ] = startXY.current; const [startX, startY] = startXY.current;
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; const [deltaX, deltaY] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (target.localName === 'button' || target.localName === 'a' || (parentNode && (parentNode.localName === 'button' || parentNode.localName === 'a'))) { if (target.localName === 'button' || target.localName === 'a' || (parentNode && (parentNode.localName === 'button' || parentNode.localName === 'a'))) {
return; return;
@ -273,11 +273,12 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
output.push(<ReadMoreButton onClick={onClick} key='read-more' />); output.push(<ReadMoreButton onClick={onClick} key='read-more' />);
} }
if (status.poll && typeof status.poll === 'string') { const hasPoll = status.poll && typeof status.poll === 'string';
if (hasPoll) {
output.push(<Poll id={status.poll} key='poll' status={status.url} />); output.push(<Poll id={status.poll} key='poll' status={status.url} />);
} }
return <>{output}</>; return <div className={classNames({ 'bg-gray-100 rounded-md p-4': hasPoll })}>{output}</div>;
} else { } else {
const output = [ const output = [
<div <div

Wyświetl plik

@ -18,9 +18,9 @@ const messages = defineMessages({
* These get embedded into the build, but only in this chunk, so it's okay. * These get embedded into the build, but only in this chunk, so it's okay.
*/ */
const MOCK_STATUSES: any[] = [ const MOCK_STATUSES: any[] = [
require('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json'),
require('soapbox/__fixtures__/pleroma-status-with-poll.json'), require('soapbox/__fixtures__/pleroma-status-with-poll.json'),
require('soapbox/__fixtures__/pleroma-status-vertical-video-without-metadata.json'), require('soapbox/__fixtures__/pleroma-status-vertical-video-without-metadata.json'),
require('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json'),
require('soapbox/__fixtures__/pleroma-quote-of-quote-post.json'), require('soapbox/__fixtures__/pleroma-quote-of-quote-post.json'),
require('soapbox/__fixtures__/truthsocial-status-with-external-video.json'), require('soapbox/__fixtures__/truthsocial-status-with-external-video.json'),
]; ];

Wyświetl plik

@ -1,30 +1,44 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
import { Stack } from 'soapbox/components/ui';
import { Poll as PollEntity, PollOption as PollOptionEntity } from 'soapbox/types/entities'; import { Poll as PollEntity, PollOption as PollOptionEntity } from 'soapbox/types/entities';
interface IPollPreview { interface IPollPreview {
poll: PollEntity, poll: PollEntity,
} }
// {/* <li key={index}>
// <label className={classNames('poll__text', { selectable: !showResults })}>
// <input
// name='vote-options'
// type={poll.multiple ? 'checkbox' : 'radio'}
// disabled
// />
// <span className={classNames('poll__input', { checkbox: poll.multiple })} />
// <span dangerouslySetInnerHTML={{ __html: option.title_emojified }} />
// </label>
// </li> */}
const PollPreview: React.FC<IPollPreview> = ({ poll }) => { const PollPreview: React.FC<IPollPreview> = ({ poll }) => {
const renderOption = (option: PollOptionEntity, index: number) => { const renderOption = (option: PollOptionEntity, index: number) => {
const showResults = poll.voted || poll.expired; const showResults = poll.voted || poll.expired;
return ( return (
<li key={index}> <label className='rounded-tl-md rounded-tr-md relative border p-4 flex flex-col cursor-pointer md:pl-4 md:pr-6 md:grid md:grid-cols-3 focus:outline-none'>
<label className={classNames('poll__text', { selectable: !showResults })}> <span className='flex items-center text-sm'>
<input <input type='radio' name='pricing-plan' value='Startup' className='h-4 w-4 text-indigo-600 border-gray-300 focus:ring-indigo-500' aria-labelledby='pricing-plans-0-label' aria-describedby='pricing-plans-0-description-0 pricing-plans-0-description-1' />
name='vote-options' <span id='pricing-plans-0-label' className='ml-3 font-medium'>Startup</span>
type={poll.multiple ? 'checkbox' : 'radio'} </span>
disabled <span id='pricing-plans-0-description-0' className='ml-6 pl-1 text-sm md:ml-0 md:pl-0 md:text-center'>
/> <span className='font-medium'>$29 / mo</span>
<span>($290 / yr)</span>
<span className={classNames('poll__input', { checkbox: poll.multiple })} /> </span>
<span id='pricing-plans-0-description-1' className='ml-6 pl-1 text-sm md:ml-0 md:pl-0 md:text-right'>Up to 5 active job postings</span>
<span dangerouslySetInnerHTML={{ __html: option.title_emojified }} />
</label> </label>
</li>
); );
}; };
@ -33,11 +47,9 @@ const PollPreview: React.FC<IPollPreview> = ({ poll }) => {
} }
return ( return (
<div className='poll'> <Stack>
<ul>
{poll.options.map((option, i) => renderOption(option, i))} {poll.options.map((option, i) => renderOption(option, i))}
</ul> </Stack>
</div>
); );
}; };

Wyświetl plik

@ -780,7 +780,7 @@
"poll.closed": "Closed", "poll.closed": "Closed",
"poll.refresh": "Refresh", "poll.refresh": "Refresh",
"poll.total_votes": "{count, plural, one {# vote} other {# votes}}", "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
"poll.vote": "Vote", "poll.vote": "Submit Vote",
"poll.voted": "You voted for this answer", "poll.voted": "You voted for this answer",
"poll.votes": "{votes, plural, one {# vote} other {# votes}}", "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
"poll_button.add_poll": "Add a poll", "poll_button.add_poll": "Add a poll",