Skip to content

Commit

Permalink
Added StackedProgress election tracker component (#13007)
Browse files Browse the repository at this point in the history
This component is one of several that will allow rendering election trackers within DCAR. It's a "stacked progress bar", representing progress through an election divided up by each group running. It's generic, so the kinds of groups it can represent varies. For example:

- Candidates in a US presidential election
- Parties in a UK general election
- Party groups in an EU parliamentary election

These examples are demonstrated in the stories file also included in this change.
  • Loading branch information
JamieB-gu authored Jan 2, 2025
1 parent 61bbc35 commit 2bc6f6f
Show file tree
Hide file tree
Showing 3 changed files with 431 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { Meta, StoryObj } from '@storybook/react';
import { allModes } from '../../../.storybook/modes';
import { palette } from '../../palette';
import { StackedProgress } from './StackedProgress';

const meta = {
title: 'Components/Election Trackers/Stacked Progress',
component: StackedProgress,
decorators: (Story) => (
<div css={{ paddingTop: 60, paddingBottom: 30 }}>
<Story />
</div>
),
parameters: {
viewport: {
defaultViewport: 'mobileLandscape',
},
chromatic: {
modes: {
'vertical mobileLandscape':
allModes['vertical mobileLandscape'],
},
},
},
} satisfies Meta<typeof StackedProgress>;

export default meta;

type Story = StoryObj<typeof meta>;

export const UKGeneral = {
args: {
total: 650,
toWinCopy: 'for majority',
sections: [
{
name: 'Labour',
colour: palette('--uk-elections-labour'),
value: 400,
align: 'left',
},
{
name: 'Conservative',
colour: palette('--uk-elections-conservative'),
value: 100,
align: 'right',
},
{
name: 'Lib Dem',
colour: palette('--uk-elections-lib-dem'),
value: 70,
align: 'left',
},
{
name: 'SNP',
colour: palette('--uk-elections-snp'),
value: 10,
align: 'left',
},
{
name: 'Reform',
colour: palette('--uk-elections-reform'),
value: 5,
align: 'right',
},
],
},
} satisfies Story;

export const USPresidential = {
args: {
total: 538,
toWinCopy: 'to win',
sections: [
{
name: 'Harris',
colour: palette('--us-elections-democrats'),
value: 200,
align: 'left',
},
{
name: 'Trump',
colour: palette('--us-elections-republicans'),
value: 200,
align: 'right',
},
],
},
} satisfies Story;

export const EUParliament = {
args: {
total: 720,
toWinCopy: undefined,
sections: [
{
colour: palette('--eu-parliament-theleft'),
name: 'Left',
value: 40,
align: 'left',
},
{
name: 'S&D',
colour: palette('--eu-parliament-sd'),
value: 100,
align: 'left',
},
{
name: 'Grn/EFA',
colour: palette('--eu-parliament-greensefa'),
value: 40,
align: 'left',
},
{
name: 'Renew',
colour: palette('--eu-parliament-renew'),
value: 60,
align: 'left',
},
{
name: 'EPP',
colour: palette('--eu-parliament-epp'),
value: 150,
align: 'left',
},
{
name: 'ECR',
colour: palette('--eu-parliament-ecr'),
value: 60,
align: 'left',
},
{
name: 'NI',
colour: palette('--eu-parliament-ni'),
value: 30,
align: 'left',
},
{
name: 'PfE',
colour: palette('--eu-parliament-unknown'),
value: 70,
align: 'left',
},
{
name: 'ESN',
colour: palette('--eu-parliament-unknown'),
value: 20,
align: 'left',
},
],
},
} satisfies Story;
207 changes: 207 additions & 0 deletions dotcom-rendering/src/components/ElectionTrackers/StackedProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { from, textSans12 } from '@guardian/source/foundations';
import type { ReactNode } from 'react';
import { palette } from '../../palette';

type Props = {
/**
* The sections into which the stacked progress bar will be broken. For more
* information see {@linkcode Section}.
*/
sections: Section[];
/**
* The maximum number the stacked progress bar can reach. For an election,
* this would be the number of results expected. Must be an integer (a whole
* number).
*
* **Examples:** number of constituencies up for election; total electoral
* college votes.
*/
total: number;
/**
* When this is specified, the bar will include a line down the centre that
* represents a target needed to win the election by achieving a majority.
* The groups being elected can then be arranged on either side of this line
* by setting their {@linkcode Section.align|align} property. The majority
* needed will be calculated automatically based on the
* {@linkcode Props.total|total}.
*
* The copy specified here will be prefixed by the majority number and used
* to label the stacked progress bar, and will appear above the central
* line.
*
* **Examples:** Specify {@linkcode Props.total|total} as 538 and this prop
* as "to win" to get "270 to win"; specify {@linkcode Props.total|total} as
* 650 and this prop as "for majority" to get "326 for majority".
*/
toWinCopy: string | undefined;
};

/**
* A section of the stacked progress bar. For an election each section would
* represent a group that's running. Examples: seats won by a party; votes won
* by a candidate.
*/
type Section = {
/**
* The colour used to represent the group in the stacked progress bar. It
* expects a CSS `color` value (e.g. a hex string). To ensure dark mode
* support a {@linkcode palette} colour can be used; i.e. this property
* can be set to the return value of the {@linkcode palette} function.
*/
colour: string;
/**
* The size of a particular section of the progress bar, less than the
* {@linkcode Props.total|total}. For an election, this would be the result
* for the group in question.
*
* **Examples:** seats won by a party; votes won by a candidate.
*/
value: number;
/**
* The name of the section in the stacked progress bar. For an election,
* this would be the name of the group. It will be used to provide an
* accessible description of that section and as a React "key" for the
* element, so each section's `name` should be unique relative to the
* other sections.
*
* **Examples:** name of a candidate; name of a party.
*/
name: string;
/**
* Aligns a section to the left or right side of the stacked progress bar.
* For an election this can be used to represent two or more groups in
* opposition to one another. When used in conjunction with
* {@linkcode Props.toWinCopy|toWinCopy} it can be used to show two or more
* groups competing for a majority.
*/
align: 'left' | 'right';
};

/**
* Represents progress towards a goal divided into groups. Designed to be used
* in election trackers, where it can be used to show progress through an
* election divided up by each group running.
*
* It's generic, so the kinds of groups it can represent varies. For example:
*
* - Candidates in a US presidential election
* - Parties in a UK general election
* - Party groups in an EU parliamentary election
*
* These examples are demonstrated in the stories for this component.
*/
export const StackedProgress = ({ sections, total, toWinCopy }: Props) => {
const value = sections.reduce((acc, section) => acc + section.value, 0);

return (
<Label total={total} toWinCopy={toWinCopy}>
<div
aria-label={`Progress to ${total}`}
role="progressbar"
aria-valuetext={valueText(value, sections)}
aria-valuenow={value}
aria-valuemax={total}
css={{
display: 'flex',
width: '100%',
height: '48px',
[from.desktop]: {
height: '44px',
},
}}
>
{sections
.filter((section) => section.align === 'left')
.map((section) => (
<SectionDiv
section={section}
total={total}
key={section.name}
/>
))}
<SectionDiv section={spacer(total, value)} total={total} />
{sections
.filter((section) => section.align === 'right')
.toReversed()
.map((section) => (
<SectionDiv
section={section}
total={total}
key={section.name}
/>
))}
</div>
</Label>
);
};

type SectionDivProps = {
section: Section;
total: number;
};

const SectionDiv = ({ section, total }: SectionDivProps) => (
<div
css={{
flex: `0 1 ${(section.value * 100) / total}%`,
backgroundColor: section.colour,
}}
/>
);

type LabelProps = {
children: ReactNode;
total: number;
toWinCopy: string | undefined;
};

const Label = ({ children, total, toWinCopy }: LabelProps) =>
toWinCopy === undefined ? (
<>{children}</>
) : (
<label
css={{
position: 'relative',
display: 'block',
'&:after': {
content: '""',
position: 'absolute',
backgroundColor: palette('--stacked-progress-to-win'),
height: '120%',
width: '1px',
left: 'calc(50% - 0.5px)',
top: '-15%',
},
}}
>
<span
css={[
{
position: 'absolute',
left: 'calc(50%)',
transform: 'translateX(-50%)',
top: '-55%',
color: palette('--stacked-progress-to-win'),
},
textSans12,
]}
>
{toWin(total)} {toWinCopy}
</span>
{children}
</label>
);

const spacer = (total: number, value: number): Section => ({
colour: palette('--stacked-progress-background'),
value: total - value,
name: 'spacer',
align: 'left',
});

const toWin = (total: number): number => Math.floor(total / 2) + 1;

const valueText = (value: number, sections: Section[]): string =>
`Progress so far: ${value}, values: ${sections
.map((section) => `${section.name} ${section.value}`)
.join(', ')}`;
Loading

0 comments on commit 2bc6f6f

Please sign in to comment.