diff --git a/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.stories.tsx b/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.stories.tsx index 98672c477cf..db71210bcc0 100644 --- a/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.stories.tsx +++ b/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.stories.tsx @@ -1,9 +1,10 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { expect, within } from 'storybook/test'; import { allModes } from '../../../.storybook/modes'; +import preview from '../../../.storybook/preview'; import { palette } from '../../palette'; import { StackedProgress } from './StackedProgress'; -const meta = { +const meta = preview.meta({ title: 'Components/Election Trackers/Stacked Progress', component: StackedProgress, decorators: (Story) => ( @@ -12,9 +13,6 @@ const meta = { ), parameters: { - viewport: { - defaultViewport: 'mobileLandscape', - }, chromatic: { modes: { 'vertical mobileLandscape': @@ -22,131 +20,250 @@ const meta = { }, }, }, -} satisfies Meta; - -export default meta; +}); -type Story = StoryObj; - -export const UKGeneral = { +export const UKGeneral = meta.story({ args: { total: 650, - toWinCopy: 'for majority', + label: 'for majority', + calculateWinner: true, + excludedCopy: undefined, sections: [ { name: 'Labour', colour: palette('--uk-elections-labour'), value: 400, align: 'left', + exclude: false, }, { name: 'Conservative', colour: palette('--uk-elections-conservative'), value: 100, align: 'right', + exclude: false, }, { name: 'Lib Dem', colour: palette('--uk-elections-liberal-democrat'), value: 70, align: 'left', + exclude: false, }, { name: 'SNP', colour: palette('--uk-elections-scottish-national-party'), value: 10, align: 'left', + exclude: false, }, { name: 'Reform', colour: palette('--uk-elections-reform-uk'), value: 5, align: 'right', + exclude: false, }, ], }, -} satisfies Story; + async play({ args, canvasElement }) { + const canvas = within(canvasElement); + const bars = canvas.getAllByRole('progressbar'); + + for (const bar of bars) { + // Label + const label = bar.parentElement; + await expect(label?.nodeName).toBe('LABEL'); + await expect(label?.textContent).toBe(`326 ${args.label}`); + + // Sections + await expect(bar.children).toHaveLength(args.sections.length + 1); + } + }, +}); -export const USPresidential = { +export const USPresidential = meta.story({ args: { total: 538, - toWinCopy: 'to win', + label: 'to win', + calculateWinner: true, + excludedCopy: undefined, sections: [ { name: 'Harris', colour: palette('--us-elections-democrats'), value: 200, align: 'left', + exclude: false, }, { name: 'Trump', colour: palette('--us-elections-republicans'), value: 200, align: 'right', + exclude: false, + }, + ], + }, +}); + +/** + * Results from 2024: + * https://www.theguardian.com/us-news/ng-interactive/2024/nov/14/us-house-senate-and-governor-elections-2024-results-from-all-50-states + */ +export const USSenate = meta.story({ + args: { + total: 34, + label: '50', + calculateWinner: false, + excludedCopy: 'No election', + sections: [ + { + name: 'Democrats', + colour: palette('--us-elections-democrats-alt'), + value: 28, + align: 'left', + exclude: true, + }, + { + name: 'Democrats', + colour: palette('--us-elections-democrats'), + value: 19, + align: 'left', + exclude: false, + }, + { + name: 'Republicans', + colour: palette('--us-elections-republicans-alt'), + value: 38, + align: 'right', + exclude: true, + }, + { + name: 'Republicans', + colour: palette('--us-elections-republicans'), + value: 15, + align: 'right', + exclude: false, }, ], }, -} satisfies Story; + async play({ args, canvasElement }) { + const canvas = within(canvasElement); + const bars = canvas.getAllByRole('progressbar'); + + for (const bar of bars) { + // Progress bar + await expect(bar.ariaLabel).toBe( + `Progress to ${args.total.toString()}`, + ); + await expect(bar).toHaveValue( + args.sections[1]!.value + args.sections[3]!.value, + ); + await expect(bar).toHaveAttribute( + 'aria-valuemax', + args.total.toString(), + ); + await expect(bar).toHaveAttribute( + 'aria-valuetext', + `Progress so far: 34, values: Democrats ${args.sections[1]?.value}, Republicans ${args.sections[3]?.value}, No election: Democrats ${args.sections[0]?.value}, Republicans ${args.sections[2]?.value}.`, + ); + + // Label + const label = bar.parentElement; + await expect(label?.nodeName).toBe('LABEL'); + await expect(label?.textContent).toBe(args.label); + + // Sections + await expect(bar.children).toHaveLength(args.sections.length + 1); + } + }, +}); -export const EUParliament = { +export const EUParliament = meta.story({ args: { total: 720, - toWinCopy: undefined, + label: undefined, + calculateWinner: false, + excludedCopy: undefined, sections: [ { colour: palette('--eu-parliament-theleft'), name: 'Left', value: 40, align: 'left', + exclude: false, }, { name: 'S&D', colour: palette('--eu-parliament-sd'), value: 100, align: 'left', + exclude: false, }, { name: 'Grn/EFA', colour: palette('--eu-parliament-greensefa'), value: 40, align: 'left', + exclude: false, }, { name: 'Renew', colour: palette('--eu-parliament-renew'), value: 60, align: 'left', + exclude: false, }, { name: 'EPP', colour: palette('--eu-parliament-epp'), value: 150, align: 'left', + exclude: false, }, { name: 'ECR', colour: palette('--eu-parliament-ecr'), value: 60, align: 'left', + exclude: false, }, { name: 'NI', colour: palette('--eu-parliament-ni'), value: 30, align: 'left', + exclude: false, }, { name: 'PfE', colour: palette('--eu-parliament-unknown'), value: 70, align: 'left', + exclude: false, }, { name: 'ESN', colour: palette('--eu-parliament-unknown'), value: 20, align: 'left', + exclude: false, }, ], }, -} satisfies Story; + async play({ args, canvasElement }) { + const canvas = within(canvasElement); + const bars = canvas.getAllByRole('progressbar'); + + for (const bar of bars) { + // No label + const noLabel = bar.parentElement; + await expect(noLabel?.nodeName).toBe('DIV'); + await expect(noLabel?.textContent).toBe(''); + + // Sections + await expect(bar.children).toHaveLength(args.sections.length + 1); + } + }, +}); diff --git a/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.tsx b/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.tsx index 53f4eaa7ff0..9a42866c4ed 100644 --- a/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.tsx +++ b/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.tsx @@ -1,4 +1,4 @@ -import { from, textSans12 } from '@guardian/source/foundations'; +import { from, textSans12Object } from '@guardian/source/foundations'; import type { ReactNode } from 'react'; import { palette } from '../../palette'; @@ -11,12 +11,31 @@ type Props = { /** * 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). + * number). If there are excluded sections, e.g. "holdovers", seats that are + * not up for election this time, they **should not** be included in this + * 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 and + * apply the label text to it. The groups being elected can then be arranged + * on either side of this line by setting their + * {@linkcode Section.align|align} property. If + * {@linkcode Props.calculateWinner|calculateWinner} is set to `true`, that + * number will be prepended onto this label. + * + * @example + * This will generate the text "326 for majority" + * + */ + label: string | undefined; /** * 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. @@ -25,15 +44,32 @@ type Props = { * 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. + * If {@linkcode Props.label|label} is also specified, that text will be + * appended to the calculated number. + * + * @example + * This will generate the text "270 to win" + * * - * **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". + * @example + * This will generate the text "326 for majority" + * + */ + calculateWinner: boolean; + /** + * If there are sections excluded from the results (see + * {@linkcode Section.exclude|exclude}), this copy will be shown alongside + * those sections. For example, "No election". + */ + excludedCopy: string | undefined; + /** + * Allows additional styles to be passed via the `css` prop. */ - toWinCopy: string | undefined; + className?: string; }; /** @@ -75,6 +111,13 @@ type Section = { * groups competing for a majority. */ align: 'left' | 'right'; + /** + * Whether this section should be included or excluded from calculations. If + * excluded it will be styled in a different way to sections that are + * included. Useful for representing "holdovers", i.e. seats that are not up + * for election this time. + */ + exclude: boolean; }; /** @@ -90,75 +133,184 @@ type Section = { * * 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); +export const StackedProgress = (props: Props) => { + const value = barValue(props.sections); + const totalWithExcluded = barTotalWithExcluded(props.sections, props.total); + const includeLabel = props.label !== undefined || props.calculateWinner; return ( - ); }; -type SectionDivProps = { - section: Section; +/** + * The progress made through the election to be represented on the progress bar, + * ignoring excluded sections (e.g. seats that are not up for election this + * time). + */ +const barValue = (sections: Section[]): number => + sections.reduce( + (value, section) => (section.exclude ? value : value + section.value), + 0, + ); + +/** + * Although the progress of the bar is towards a total provided in + * {@linkcode Props.total|total}, the actual drawn bar is larger because it + * contains excluded sections too (e.g. seats that are not up for election this + * time). Therefore, to get the full width of the bar we have to add on all the + * values of the excluded sections. + */ +const barTotalWithExcluded = (sections: Section[], total: number) => + sections.reduce( + (acc, section) => (section.exclude ? acc + section.value : acc), + total, + ); + +/** + * The enclosing progress bar, with {@linkcode SectionDiv}s to be passed as + * `children`. + */ +const ProgressBar = (props: { total: number; -}; + value: number; + sections: Section[]; + children: ReactNode; + excludedCopy: string | undefined; + className?: string; +}) => ( +
+ {props.children} +
+); -const SectionDiv = ({ section, total }: SectionDivProps) => ( +/** + * The sections to be included in the {@linkcode ProgressBar}. + */ +const SectionDiv = (props: { + section: Section; + totalWithExcluded: number; + excludedCopy: string | undefined; +}) => (
); -type LabelProps = { - children: ReactNode; - total: number; - toWinCopy: string | undefined; -}; +/** + * Excluded sections in the bar are represented with a vertical stripe pattern. + */ +const excludedBackground = (colour: string): string => + `repeating-linear-gradient(${[ + 'to right', + colour, + `${colour} 1px`, + `${palette('--stacked-progress-excluded-background')} 1px`, + `${palette('--stacked-progress-excluded-background')} 2px`, + ].join(', ')})`; -const Label = ({ children, total, toWinCopy }: LabelProps) => - toWinCopy === undefined ? ( - <>{children} - ) : ( +/** + * The label that may appear above the bar alongside a central line. See + * {@linkcode Props.label|label} and + * {@linkcode Props.calculateWinner|calculateWinner}. + */ +const Label = (props: { + children: ReactNode; + totalWithExcluded: number; + includeLabel: boolean; + label: string | undefined; + calculateWinner: boolean; + className: string | undefined; +}) => + props.includeLabel ? ( + ) : ( + <>{props.children} ); +/** + * An empty section to create space in the bar before progress has reached 100%. + */ const spacer = (total: number, value: number): Section => ({ colour: palette('--stacked-progress-background'), value: total - value, name: 'spacer', align: 'left', + exclude: false, }); -const toWin = (total: number): number => Math.floor(total / 2) + 1; +/** + * For a bar representing competing values on the left and right, where success + * is measured as a majority (e.g. in elections). This calculates the number at + * which a majority is reached, based on the total size of the bar. + */ +const toWin = (totalWithExcluded: number): number => + Math.floor(totalWithExcluded / 2) + 1; + +/** + * Describes the state of the bar in words. + */ +const valueText = ( + value: number, + sections: Section[], + excludedCopy: string | undefined, +): string => { + const included = sections.filter((section) => !section.exclude); + const excluded = sections.filter((section) => section.exclude); + + const includedSummary = `values: ${summary(included)}`; + const excludedSummary = + excluded.length > 0 + ? `, ${excludedCopy ?? 'excluded'}: ${summary(excluded)}` + : ''; + + return `Progress so far: ${value}, ${includedSummary}${excludedSummary}.`; +}; -const valueText = (value: number, sections: Section[]): string => - `Progress so far: ${value}, values: ${sections - .map((section) => `${section.name} ${section.value}`) - .join(', ')}`; +const summary = (sections: Section[]): string => + sections.map((section) => `${section.name} ${section.value}`).join(', '); diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts index 3fb76dea59d..b1400922251 100644 --- a/dotcom-rendering/src/paletteDeclarations.ts +++ b/dotcom-rendering/src/paletteDeclarations.ts @@ -8226,6 +8226,10 @@ const paletteColours = { */ dark: () => '#606060', }, + '--stacked-progress-excluded-background': { + light: () => sourcePalette.neutral[100], + dark: () => sourcePalette.neutral[38], + }, '--stacked-progress-to-win': { light: () => sourcePalette.neutral[7], dark: () => sourcePalette.neutral[86], @@ -8502,10 +8506,18 @@ const paletteColours = { light: () => '#093CA3', dark: () => '#3261DB', }, + '--us-elections-democrats-alt': { + light: () => '#DAD7F5', + dark: () => '#DAD7F5', + }, '--us-elections-republicans': { light: () => sourcePalette.news[400], dark: () => '#DC2E1C', }, + '--us-elections-republicans-alt': { + light: () => '#FFDBD4', + dark: () => '#FFDBD4', + }, '--values-with-change-border': { light: () => sourcePalette.neutral[86], dark: () => sourcePalette.neutral[86],