From e84e2daa59c20bc79fe54d7612f3132664cb308d Mon Sep 17 00:00:00 2001 From: Kofi Ohene-Adu Date: Thu, 8 Feb 2024 15:48:32 -0600 Subject: [PATCH] [BMD] Update voter-facing screen layouts to make room for language picker (#4589) --- .../src/components/centered_page_layout.tsx | 12 +- .../src/lib/assistive_technology.test.tsx | 15 +- .../pages/ballot_successfully_cast_page.tsx | 4 +- ...rm_exit_pat_device_identification_page.tsx | 54 +++---- .../pat_device_identification_page.tsx | 40 +++-- .../src/pages/validate_ballot_page.tsx | 80 +++++---- apps/mark/frontend/src/lib/gamepad.test.tsx | 15 +- .../e2e/scroll_buttons.spec.ts | 8 +- .../src/components/contest_header.tsx | 36 +++-- .../components/display_settings_button.tsx | 19 +-- .../src/components/voter_screen.tsx | 153 ++++++++++++++++++ libs/mark-flow-ui/src/index.ts | 1 + .../src/pages/cast_ballot_page.tsx | 77 ++++----- libs/mark-flow-ui/src/pages/contest_page.tsx | 63 ++++---- libs/mark-flow-ui/src/pages/review_page.tsx | 52 +++--- libs/mark-flow-ui/src/pages/start_page.tsx | 60 ++----- libs/ui/src/contest_choice_button.tsx | 15 +- libs/ui/src/icons.tsx | 5 + libs/ui/src/index.ts | 1 + libs/ui/src/language_settings/index.ts | 1 + .../language_settings_button.tsx | 39 +++++ libs/ui/src/ui_strings/read_on_load.test.tsx | 25 +++ libs/ui/src/ui_strings/read_on_load.tsx | 13 ++ 23 files changed, 490 insertions(+), 298 deletions(-) create mode 100644 libs/mark-flow-ui/src/components/voter_screen.tsx create mode 100644 libs/ui/src/language_settings/index.ts create mode 100644 libs/ui/src/language_settings/language_settings_button.tsx diff --git a/apps/mark-scan/frontend/src/components/centered_page_layout.tsx b/apps/mark-scan/frontend/src/components/centered_page_layout.tsx index 337559145..fe23f9caf 100644 --- a/apps/mark-scan/frontend/src/components/centered_page_layout.tsx +++ b/apps/mark-scan/frontend/src/components/centered_page_layout.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Font, H1, Main, ReadOnLoad, Screen } from '@votingworks/ui'; -import { ButtonFooter } from '@votingworks/mark-flow-ui'; +import { VoterScreen, ButtonFooter } from '@votingworks/mark-flow-ui'; export interface CenteredPageLayoutProps { buttons?: React.ReactNode; @@ -22,10 +22,18 @@ export function CenteredPageLayout( ); + if (voterFacing) { + return ( + + {mainContent} + + ); + } + return (
- {voterFacing ? {mainContent} : mainContent} + {mainContent}
{buttons && {buttons}}
diff --git a/apps/mark-scan/frontend/src/lib/assistive_technology.test.tsx b/apps/mark-scan/frontend/src/lib/assistive_technology.test.tsx index e8ae2e74a..2a7da4b10 100644 --- a/apps/mark-scan/frontend/src/lib/assistive_technology.test.tsx +++ b/apps/mark-scan/frontend/src/lib/assistive_technology.test.tsx @@ -1,7 +1,7 @@ import { MemoryHardware, ALL_PRECINCTS_SELECTION } from '@votingworks/utils'; import { electionGeneralDefinition } from '@votingworks/fixtures'; import userEvent from '@testing-library/user-event'; -import { render, screen } from '../../test/react_testing_library'; +import { render, screen, waitFor } from '../../test/react_testing_library'; import { App } from '../app'; import { advanceTimersAndPromises } from '../../test/helpers/timers'; @@ -66,11 +66,16 @@ it('accessible controller handling works', async () => { expect(getActiveElement()).toHaveTextContent(contest0candidate1.name); userEvent.keyboard('[ArrowUp]'); expect(getActiveElement()).toHaveTextContent(contest0candidate0.name); + // test the edge case of rolling over - userEvent.keyboard('[ArrowUp]'); - expect(document.activeElement!.textContent).toEqual('Next'); - userEvent.keyboard('[ArrowDown]'); - expect(getActiveElement()).toHaveTextContent(contest0candidate0.name); + await waitFor(() => { + userEvent.keyboard('[ArrowUp]'); + expect(getActiveElement()).toHaveTextContent(contest0candidate1.name); + }); + await waitFor(() => { + userEvent.keyboard('[ArrowDown]'); + expect(getActiveElement()).toHaveTextContent(contest0candidate0.name); + }); userEvent.keyboard('[ArrowRight]'); await advanceTimersAndPromises(); diff --git a/apps/mark-scan/frontend/src/pages/ballot_successfully_cast_page.tsx b/apps/mark-scan/frontend/src/pages/ballot_successfully_cast_page.tsx index ffe3e20ac..3fc458eb7 100644 --- a/apps/mark-scan/frontend/src/pages/ballot_successfully_cast_page.tsx +++ b/apps/mark-scan/frontend/src/pages/ballot_successfully_cast_page.tsx @@ -5,13 +5,11 @@ import { P, appStrings, } from '@votingworks/ui'; -import { DisplaySettingsButton } from '@votingworks/mark-flow-ui'; import { CenteredPageLayout } from '../components/centered_page_layout'; export function BallotSuccessfullyCastPage(): JSX.Element { - const settingsButton = ; return ( - + diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/confirm_exit_pat_device_identification_page.tsx b/apps/mark-scan/frontend/src/pages/pat_device_identification/confirm_exit_pat_device_identification_page.tsx index 18e0b9993..3ca1b758b 100644 --- a/apps/mark-scan/frontend/src/pages/pat_device_identification/confirm_exit_pat_device_identification_page.tsx +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/confirm_exit_pat_device_identification_page.tsx @@ -1,14 +1,6 @@ -import { - Main, - Screen, - H1, - P, - Button, - Icons, - ReadOnLoad, - appStrings, -} from '@votingworks/ui'; -import { ButtonFooter } from '@votingworks/mark-flow-ui'; +import React from 'react'; +import { H1, P, Button, Icons, ReadOnLoad, appStrings } from '@votingworks/ui'; +import { VoterScreen } from '@votingworks/mark-flow-ui'; import { PortraitStepInnerContainer } from './portrait_step_inner_container'; interface Props { @@ -21,24 +13,26 @@ export function ConfirmExitPatDeviceIdentificationPage({ onPressContinue, }: Props): JSX.Element { return ( - -
- - - -

{appStrings.titleBmdPatCalibrationConfirmExitScreen()}

-

{appStrings.instructionsBmdPatCalibrationConfirmExitScreen()}

-
-
-
- - - - -
+ + + + + } + > + + + +

{appStrings.titleBmdPatCalibrationConfirmExitScreen()}

+

{appStrings.instructionsBmdPatCalibrationConfirmExitScreen()}

+
+
+
); } diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_identification_page.tsx b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_identification_page.tsx index 5c1516f43..0eb289630 100644 --- a/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_identification_page.tsx +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_identification_page.tsx @@ -1,6 +1,4 @@ import { - Main, - Screen, P, Font, Button, @@ -8,7 +6,7 @@ import { appStrings, } from '@votingworks/ui'; import { useCallback, useState, useEffect } from 'react'; -import { ButtonFooter } from '@votingworks/mark-flow-ui'; +import { VoterScreen } from '@votingworks/mark-flow-ui'; import styled from 'styled-components'; import { DiagnosticScreenHeader, @@ -78,26 +76,26 @@ export function PatDeviceIdentificationPage({ }; return ( - -
- - -

- - {appStrings.titleBmdPatCalibrationIdentificationPage()} - -
- {statusStrings[currentStepId]} -

-
- {steps[currentStepId]} -
-
- + {appStrings.buttonBmdSkipPatCalibration()} - -
+ } + centerContent + > + + +

+ + {appStrings.titleBmdPatCalibrationIdentificationPage()} + +
+ {statusStrings[currentStepId]} +

+
+ {steps[currentStepId]} +
+ ); } diff --git a/apps/mark-scan/frontend/src/pages/validate_ballot_page.tsx b/apps/mark-scan/frontend/src/pages/validate_ballot_page.tsx index 68da1e862..861561772 100644 --- a/apps/mark-scan/frontend/src/pages/validate_ballot_page.tsx +++ b/apps/mark-scan/frontend/src/pages/validate_ballot_page.tsx @@ -1,12 +1,10 @@ /* istanbul ignore file - placeholder component that will change */ -import { useContext } from 'react'; +import React from 'react'; import styled from 'styled-components'; import { - Screen, H1, WithScrollButtons, - Main, Button, appStrings, AudioOnly, @@ -14,11 +12,7 @@ import { } from '@votingworks/ui'; import { assert } from '@votingworks/basics'; -import { - ButtonFooter, - DisplaySettingsButton, - Review, -} from '@votingworks/mark-flow-ui'; +import { VoterScreen, Review } from '@votingworks/mark-flow-ui'; import { getElectionDefinition, getInterpretation, @@ -36,15 +30,13 @@ export function ValidateBallotPage(): JSX.Element | null { const getInterpretationQuery = getInterpretation.useQuery(); const getElectionDefinitionQuery = getElectionDefinition.useQuery(); // We use the contest data stored in BallotContext but vote data from the interpreted ballot - const { contests, precinctId, resetBallot } = useContext(BallotContext); + const { contests, precinctId, resetBallot } = React.useContext(BallotContext); assert( typeof precinctId !== 'undefined', 'precinctId is required to render ValidateBallotPage' ); - const settingsButton = ; - const invalidateBallotMutation = invalidateBallot.useMutation(); const validateBallotMutation = validateBallot.useMutation(); function invalidateBallotCallback() { @@ -76,38 +68,38 @@ export function ValidateBallotPage(): JSX.Element | null { const { votes } = interpretation; return ( - -
- -

{appStrings.titleBmdReviewScreen()}

- - {appStrings.instructionsBmdReviewPageNavigation()}{' '} - {appStrings.instructionsBmdScanReviewConfirmation()} - -
- - - -
- - {settingsButton} - - - -
+ + + + + } + > + +

{appStrings.titleBmdReviewScreen()}

+ + {appStrings.instructionsBmdReviewPageNavigation()}{' '} + {appStrings.instructionsBmdScanReviewConfirmation()} + +
+ + + +
); } diff --git a/apps/mark/frontend/src/lib/gamepad.test.tsx b/apps/mark/frontend/src/lib/gamepad.test.tsx index 6d0654973..b9e89ba8d 100644 --- a/apps/mark/frontend/src/lib/gamepad.test.tsx +++ b/apps/mark/frontend/src/lib/gamepad.test.tsx @@ -6,6 +6,7 @@ import { fireEvent, render, screen, + waitFor, } from '../../test/react_testing_library'; import { App } from '../app'; @@ -87,10 +88,16 @@ it('gamepad controls work', async () => { expect(getActiveElement()).toHaveTextContent(contest0candidate0.name); // test the edge case of rolling over - handleGamepadButtonDown('DPadUp'); - expect(document.activeElement!.textContent).toEqual('Next'); - handleGamepadButtonDown('DPadDown'); - expect(getActiveElement()).toHaveTextContent(contest0candidate0.name); + + await waitFor(() => { + handleGamepadButtonDown('DPadUp'); + expect(getActiveElement()).toHaveTextContent(contest0candidate1.name); + }); + + await waitFor(() => { + handleGamepadButtonDown('DPadDown'); + expect(getActiveElement()).toHaveTextContent(contest0candidate0.name); + }); handleGamepadButtonDown('DPadRight'); await advanceTimersAndPromises(); diff --git a/apps/mark/integration-testing/e2e/scroll_buttons.spec.ts b/apps/mark/integration-testing/e2e/scroll_buttons.spec.ts index 0761763fc..cb43f6d9e 100644 --- a/apps/mark/integration-testing/e2e/scroll_buttons.spec.ts +++ b/apps/mark/integration-testing/e2e/scroll_buttons.spec.ts @@ -75,18 +75,18 @@ test('configure, open polls, and test contest scroll buttons', async ({ }) .click(); - await page.getByText(/contest number: 1/i).waitFor(); + await page + .getByText(/contest number: 1/i) + .first() // Rendered multiple times as visible and audio-only elements. + .waitFor(); await page.getByRole('button', { name: /next/i }).click(); - await page.getByText(/contest number: 2/i).waitFor(); await page.getByRole('button', { name: /next/i }).click(); - await page.getByText(/contest number: 3/i).waitFor(); await expect(page.getByText('Brad Plunkard')).toBeInViewport(); expect(await findMoreButtons(page)).toHaveLength(0); await page.getByRole('button', { name: /next/i }).click(); - await page.getByText(/contest number: 4/i).waitFor(); // first candidate in the list should be visible await expect(page.getByText('Charlene Franz')).toBeInViewport(); diff --git a/libs/mark-flow-ui/src/components/contest_header.tsx b/libs/mark-flow-ui/src/components/contest_header.tsx index 07278a909..ad6978bfe 100644 --- a/libs/mark-flow-ui/src/components/contest_header.tsx +++ b/libs/mark-flow-ui/src/components/contest_header.tsx @@ -2,6 +2,7 @@ import React from 'react'; import styled from 'styled-components'; import { + AudioOnly, Caption, H2, NumberString, @@ -28,22 +29,20 @@ const Container = styled.div` padding: 0.25rem 0.5rem 0.5rem; `; -const ContestInfo = styled.div` - display: flex; - flex-direction: row-reverse; - gap: 0.5rem; - justify-content: space-between; -`; - -function Breadcrumbs(props: BreadcrumbMetadata) { +export function Breadcrumbs(props: BreadcrumbMetadata): React.ReactNode { const { ballotContestCount, contestNumber } = props; return ( {appStrings.labelContestNumber()}{' '} - |{' '} - {appStrings.labelTotalContests()}{' '} - {' '} + + {ballotContestCount && ( + + {' '} + | {appStrings.labelTotalContests()}{' '} + {' '} + + )} ); } @@ -54,12 +53,21 @@ export function ContestHeader(props: ContestHeaderProps): JSX.Element { return ( - - {breadcrumbs && } + {/* + * NOTE: This is visually rendered elsewhere in the screen footer, but + * needs to be spoken on contest navigation for the benefit of + * vision-impaired voters: + */} + {breadcrumbs && ( + + + + )} +
{electionStrings.districtName(district)} - +

{electionStrings.contestTitle(contest)}

diff --git a/libs/mark-flow-ui/src/components/display_settings_button.tsx b/libs/mark-flow-ui/src/components/display_settings_button.tsx index f2aabe907..d0666fbe6 100644 --- a/libs/mark-flow-ui/src/components/display_settings_button.tsx +++ b/libs/mark-flow-ui/src/components/display_settings_button.tsx @@ -1,29 +1,18 @@ -import { Button, Icons, appStrings } from '@votingworks/ui'; +import { Button, appStrings } from '@votingworks/ui'; import { useHistory } from 'react-router-dom'; -import styled from 'styled-components'; import { Paths } from '../config/globals'; -const LabelContainer = styled.span` - align-items: center; - display: flex; - flex-wrap: nowrap; - justify-content: center; - gap: 0.5rem; - text-align: left; -`; - export function DisplaySettingsButton(): JSX.Element | null { const history = useHistory(); return ( ); } diff --git a/libs/mark-flow-ui/src/components/voter_screen.tsx b/libs/mark-flow-ui/src/components/voter_screen.tsx new file mode 100644 index 000000000..3359b6b41 --- /dev/null +++ b/libs/mark-flow-ui/src/components/voter_screen.tsx @@ -0,0 +1,153 @@ +/* istanbul ignore file - presentational component */ + +import React from 'react'; +import styled, { DefaultTheme } from 'styled-components'; + +import { + LanguageModalButton, + Main, + Screen, + useScreenInfo, +} from '@votingworks/ui'; +import { SizeMode } from '@votingworks/types'; + +import { DisplaySettingsButton } from './display_settings_button'; + +export interface VoterScreenProps { + actionButtons?: React.ReactNode; + breadcrumbs?: React.ReactNode; + children: React.ReactNode; + centerContent?: boolean; + padded?: boolean; +} + +const COMPACT_SIZE_MODES = new Set(['touchLarge', 'touchExtraLarge']); + +function isCompactMode(p: { theme: DefaultTheme }) { + return COMPACT_SIZE_MODES.has(p.theme.sizeMode); +} + +function getButtonBarSpacingRem(p: { theme: DefaultTheme }) { + return isCompactMode(p) ? 0.3 : 0.5; +} + +const ButtonBar = styled.div` + display: flex; + flex-direction: column; + gap: ${(p) => getButtonBarSpacingRem(p)}rem; + padding: ${(p) => getButtonBarSpacingRem(p)}rem; +`; + +const Header = styled(ButtonBar)` + border-bottom: ${(p) => p.theme.sizes.bordersRem.thick}rem solid + ${(p) => p.theme.colors.outline}; + order: 1; +`; + +const Body = styled(Main)` + order: 2; +`; + +const Footer = styled(ButtonBar)` + border-top: ${(p) => p.theme.sizes.bordersRem.thick}rem solid + ${(p) => p.theme.colors.outline}; + order: 3; +`; + +const SideBar = styled(ButtonBar)` + border-left: ${(p) => p.theme.sizes.bordersRem.thick}rem solid + ${(p) => p.theme.colors.outline}; + justify-content: center; + max-width: 35%; + order: 3; +`; + +const ButtonGrid = styled.div` + align-items: center; + display: grid; + grid-gap: ${(p) => getButtonBarSpacingRem(p)}rem; + + & > * { + height: 100%; + width: 100%; + } +`; + +const PortraitButtonGrid = styled(ButtonGrid)` + grid-template-columns: 1fr 1fr; + + /* + * For single-button grids, expand the button to fill out the whole row. + * Particularly useful for avoiding unnecessary button text wrapping at larger + * display size settings. + */ + & > *:first-child:last-child { + grid-column: 1 / span 2; + } +`; + +const LandscapeButtonGrid = styled(ButtonGrid)` + grid-template-columns: 1fr; +`; + +const BreadcrumbsContainer = styled.div` + display: flex; + justify-content: center; +`; + +/** + * Base screen layout for voter-facing screens, rendered with persistent voter + * settings menu buttons, along with optional context-specific action buttons. + */ +export function VoterScreen(props: VoterScreenProps): JSX.Element { + const { actionButtons, breadcrumbs, centerContent, children, padded } = props; + + const screenInfo = useScreenInfo(); + + const optionalBreadcrumbs = breadcrumbs && ( + {breadcrumbs} + ); + + const menuButtons = ( + + + + + ); + + if (screenInfo.isPortrait) { + return ( + // NOTE: Elements are rendered in accessible focus order and visually + // re-ordered using flex ordering (see styles above). + + + {children} + + {actionButtons && ( +
+ {optionalBreadcrumbs} + {actionButtons} +
+ )} +
+ {menuButtons} +
+
+ ); + } + + return ( + + + {children} + + + + {menuButtons} + {actionButtons} + + {optionalBreadcrumbs} + + + ); +} diff --git a/libs/mark-flow-ui/src/index.ts b/libs/mark-flow-ui/src/index.ts index e4b6308cf..1a66c7cfa 100644 --- a/libs/mark-flow-ui/src/index.ts +++ b/libs/mark-flow-ui/src/index.ts @@ -3,6 +3,7 @@ export * from './components/button_footer'; export * from './components/contest'; export * from './components/display_settings_button'; export * from './components/review'; +export * from './components/voter_screen'; export * from './config/globals'; export * from './config/types'; export * from './hooks/use_ballot_style_manager'; diff --git a/libs/mark-flow-ui/src/pages/cast_ballot_page.tsx b/libs/mark-flow-ui/src/pages/cast_ballot_page.tsx index bb950c754..7c0898acb 100644 --- a/libs/mark-flow-ui/src/pages/cast_ballot_page.tsx +++ b/libs/mark-flow-ui/src/pages/cast_ballot_page.tsx @@ -5,13 +5,12 @@ import { H1, Icons, InsertBallotImage, - Main, P, ReadOnLoad, - Screen, VerifyBallotImage, appStrings, } from '@votingworks/ui'; +import { VoterScreen } from '../components/voter_screen'; const Instructions = styled.ol` display: flex; @@ -54,12 +53,6 @@ const InstructionImageContainer = styled.div` } `; -const Done = styled.div` - position: absolute; - right: 1rem; - bottom: 1rem; -`; - interface Props { hidePostVotingInstructions: () => void; } @@ -68,39 +61,39 @@ export function CastBallotPage({ hidePostVotingInstructions, }: Props): JSX.Element { return ( - -
- -

{appStrings.titleBmdCastBallotScreen()}

-

{appStrings.instructionsBmdCastBallotPreamble()}

- - - - - - {appStrings.instructionsBmdCastBallotStep1()} - - - - - - {appStrings.instructionsBmdCastBallotStep2()} - - -

- {appStrings.noteAskPollWorkerForHelp()} -

-
- - - -
-
+ + {appStrings.buttonDone()} + + } + padded + > + +

{appStrings.titleBmdCastBallotScreen()}

+

{appStrings.instructionsBmdCastBallotPreamble()}

+ + + + + + {appStrings.instructionsBmdCastBallotStep1()} + + + + + + {appStrings.instructionsBmdCastBallotStep2()} + + +

+ {appStrings.noteAskPollWorkerForHelp()} +

+
+
); } diff --git a/libs/mark-flow-ui/src/pages/contest_page.tsx b/libs/mark-flow-ui/src/pages/contest_page.tsx index 6f688a90e..0e875771e 100644 --- a/libs/mark-flow-ui/src/pages/contest_page.tsx +++ b/libs/mark-flow-ui/src/pages/contest_page.tsx @@ -10,20 +10,14 @@ import { PrecinctId, VotesDict, } from '@votingworks/types'; -import { - Screen, - LinkButton, - useScreenInfo, - appStrings, - Button, -} from '@votingworks/ui'; +import { LinkButton, appStrings, Button } from '@votingworks/ui'; import { assert, throwIllegalValue } from '@votingworks/basics'; import { Contest, ContestProps } from '../components/contest'; -import { ButtonFooter } from '../components/button_footer'; -import { DisplaySettingsButton } from '../components/display_settings_button'; import { ContestsWithMsEitherNeither } from '../utils/ms_either_neither_contests'; import { VoteUpdateInteractionMethod } from '../config/types'; +import { BreadcrumbMetadata, Breadcrumbs } from '../components/contest_header'; +import { VoterScreen } from '../components/voter_screen'; export interface ContestPageProps { contests: ContestsWithMsEitherNeither; @@ -56,8 +50,6 @@ export function ContestPage(props: ContestPageProps): JSX.Element { votes, } = props; - const screenInfo = useScreenInfo(); - // eslint-disable-next-line vx/gts-safe-number-parse const currentContestIndex = parseInt(contestNumber, 10); const contest = contests[currentContestIndex]; @@ -79,8 +71,12 @@ export function ContestPage(props: ContestPageProps): JSX.Element { const vote = votes[contest.id]; - const ballotContestNumber = currentContestIndex + 1; - const ballotContestsLength = contests.length; + const breadcrumbsMetadata: BreadcrumbMetadata | undefined = isReviewMode + ? undefined + : { + contestNumber: currentContestIndex + 1, + ballotContestCount: contests.length, + }; const isVoteComplete = (() => { switch (contest.type) { @@ -163,34 +159,31 @@ export function ContestPage(props: ContestPageProps): JSX.Element { ); - const settingsButton = ; - return ( - + + {isReviewMode ? ( + reviewScreenButton + ) : ( + + {previousContestButton} + {nextContestButton} + + )} + + } + breadcrumbs={ + breadcrumbsMetadata && + } + > - - {isReviewMode ? ( - - {settingsButton} - {reviewScreenButton} - - ) : ( - - {previousContestButton} - {settingsButton} - {nextContestButton} - - )} - - + ); } diff --git a/libs/mark-flow-ui/src/pages/review_page.tsx b/libs/mark-flow-ui/src/pages/review_page.tsx index 7307042f2..940d9896a 100644 --- a/libs/mark-flow-ui/src/pages/review_page.tsx +++ b/libs/mark-flow-ui/src/pages/review_page.tsx @@ -2,11 +2,8 @@ import styled from 'styled-components'; import { LinkButton, - Main, - Screen, H1, WithScrollButtons, - useScreenInfo, appStrings, AudioOnly, ReadOnLoad, @@ -15,10 +12,9 @@ import { import { assert } from '@votingworks/basics'; import { ElectionDefinition, PrecinctId, VotesDict } from '@votingworks/types'; -import { ButtonFooter } from '../components/button_footer'; import { Review, ReviewProps } from '../components/review'; import { ContestsWithMsEitherNeither } from '../utils/ms_either_neither_contests'; -import { DisplaySettingsButton } from '../components/display_settings_button'; +import { VoterScreen } from '../components/voter_screen'; const ContentHeader = styled(ReadOnLoad)` padding: 0.5rem 0.75rem 0; @@ -43,8 +39,6 @@ export function ReviewPage(props: ReviewPageProps): JSX.Element { votes, } = props; - const screenInfo = useScreenInfo(); - assert( electionDefinition, 'electionDefinition is required to render ReviewPage' @@ -60,32 +54,24 @@ export function ReviewPage(props: ReviewPageProps): JSX.Element { ); - const settingsButton = ; - return ( - -
- -

{appStrings.titleBmdReviewScreen()}

- - {appStrings.instructionsBmdReviewPageNavigation()}{' '} - {appStrings.instructionsBmdReviewPageChangingVotes()} - -
- - - -
- - {settingsButton} - {printMyBallotButton} - -
+ + +

{appStrings.titleBmdReviewScreen()}

+ + {appStrings.instructionsBmdReviewPageNavigation()}{' '} + {appStrings.instructionsBmdReviewPageChangingVotes()} + +
+ + + +
); } diff --git a/libs/mark-flow-ui/src/pages/start_page.tsx b/libs/mark-flow-ui/src/pages/start_page.tsx index 419e26e2e..526abb117 100644 --- a/libs/mark-flow-ui/src/pages/start_page.tsx +++ b/libs/mark-flow-ui/src/pages/start_page.tsx @@ -2,7 +2,6 @@ import { singlePrecinctSelectionFor } from '@votingworks/utils'; import styled from 'styled-components'; import { - Screen, Button, appStrings, AudioOnly, @@ -18,18 +17,8 @@ import { PrecinctId, } from '@votingworks/types'; import { ElectionInfo } from '../components/election_info'; -import { DisplaySettingsButton } from '../components/display_settings_button'; import { ContestsWithMsEitherNeither } from '../utils/ms_either_neither_contests'; - -const Body = styled.div` - align-items: center; - display: flex; - flex-direction: column; - flex-grow: 1; - gap: 1rem; - justify-content: center; - padding: 0.5rem; -`; +import { VoterScreen } from '../components/voter_screen'; const ElectionInfoContainer = styled.div` @media (orientation: portrait) { @@ -47,16 +36,6 @@ const StartVotingButton = styled(Button)` line-height: 2rem; `; -const Footer = styled.div` - align-items: center; - border-top: ${(p) => p.theme.sizes.bordersRem.thick}rem solid - ${(p) => p.theme.colors.outline}; - display: flex; - gap: 1rem; - justify-content: center; - padding: 0.5rem; -`; - export interface StartPageProps { ballotStyleId?: BallotStyleId; contests: ContestsWithMsEitherNeither; @@ -96,26 +75,21 @@ export function StartPage(props: StartPageProps): JSX.Element { ); return ( - - - - - - - {appStrings.instructionsBmdBallotNavigation()} - - - {startVotingButton} - - -
- -
-
+ + + + + + {appStrings.instructionsBmdBallotNavigation()} + + + {startVotingButton} + + ); } diff --git a/libs/ui/src/contest_choice_button.tsx b/libs/ui/src/contest_choice_button.tsx index e73423c8f..5cda3e152 100644 --- a/libs/ui/src/contest_choice_button.tsx +++ b/libs/ui/src/contest_choice_button.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from 'react'; -import styled, { css } from 'styled-components'; +import styled, { DefaultTheme, css } from 'styled-components'; +import { SizeMode } from '@votingworks/types'; import { Button, ButtonPressEvent, ButtonVariant } from './button'; import { Checkbox } from './checkbox'; import { Caption, P } from './typography'; @@ -27,17 +28,24 @@ interface StyleProps { variant?: ButtonVariant; } +const COMPACT_SIZE_MODES = new Set(['touchLarge', 'touchExtraLarge']); + +function isCompactMode(p: { theme: DefaultTheme }) { + return COMPACT_SIZE_MODES.has(p.theme.sizeMode); +} + const selectedChoiceStyles = css` border: ${(p) => p.theme.sizes.bordersRem.hairline}rem solid ${(p) => p.theme.colors.primary}; `; +/* istanbul ignore next */ const OuterContainer = styled(Button)` border: ${(p) => p.theme.sizes.bordersRem.hairline}rem solid currentColor; grid-area: ${(p) => p.gridArea}; justify-content: start; min-width: min-content; - padding: 0.5rem; + padding: ${(p) => (isCompactMode(p) ? '0.25rem 0.3rem' : '0.5rem')}; text-align: left; width: 100%; @@ -48,11 +56,12 @@ const OuterContainer = styled(Button)` } `; +/* istanbul ignore next */ const Content = styled.span` align-items: center; display: flex; flex-wrap: nowrap; - gap: 0.5rem; + gap: ${(p) => (isCompactMode(p) ? 0.25 : 0.5)}rem; `; const CheckboxContainer = styled.span` diff --git a/libs/ui/src/icons.tsx b/libs/ui/src/icons.tsx index 1435c06dd..1735c9486 100644 --- a/libs/ui/src/icons.tsx +++ b/libs/ui/src/icons.tsx @@ -15,6 +15,7 @@ import { faInfoCircle, faMinusCircle, faPencil, + faLanguage, faXmark, faMagnifyingGlassPlus, faMagnifyingGlassMinus, @@ -208,6 +209,10 @@ export const Icons = { return ; }, + Language(props) { + return ; + }, + Loading(props) { return ; }, diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index cc3574950..95daf9421 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -44,6 +44,7 @@ export * from './input_group'; export * from './insert_ballot_image'; export * from './insert_card_image'; export * from './labelled_text'; +export * from './language_settings'; export * from './left_nav'; export * from './link_button'; export * from './list'; diff --git a/libs/ui/src/language_settings/index.ts b/libs/ui/src/language_settings/index.ts new file mode 100644 index 000000000..afd6012ba --- /dev/null +++ b/libs/ui/src/language_settings/index.ts @@ -0,0 +1 @@ +export * from './language_settings_button'; diff --git a/libs/ui/src/language_settings/language_settings_button.tsx b/libs/ui/src/language_settings/language_settings_button.tsx new file mode 100644 index 000000000..9a360faaa --- /dev/null +++ b/libs/ui/src/language_settings/language_settings_button.tsx @@ -0,0 +1,39 @@ +/* istanbul ignore file - stub implementation */ + +import React from 'react'; + +import { useCurrentLanguage } from '../hooks/use_current_language'; +import { useAvailableLanguages } from '../hooks/use_available_languages'; +import { useLanguageControls } from '../hooks/use_language_controls'; +import { Button } from '../button'; + +export function LanguageModalButton(): React.ReactNode { + const currentLanguageCode = useCurrentLanguage(); + const availableLanguages = useAvailableLanguages(); + const { setLanguage } = useLanguageControls(); + + // TODO(kofi): Let clients trigger navigation to a language settings screen + // instead: + const onPress = React.useCallback(() => { + const currentLanguageIndex = availableLanguages.findIndex( + (l) => l === currentLanguageCode + ); + const nextIndex = (currentLanguageIndex + 1) % availableLanguages.length; + + setLanguage(availableLanguages[nextIndex]); + }, [availableLanguages, currentLanguageCode, setLanguage]); + + if (availableLanguages.length < 2) { + return null; + } + + return ( + + ); +} diff --git a/libs/ui/src/ui_strings/read_on_load.test.tsx b/libs/ui/src/ui_strings/read_on_load.test.tsx index 5519ffaa8..40def9aed 100644 --- a/libs/ui/src/ui_strings/read_on_load.test.tsx +++ b/libs/ui/src/ui_strings/read_on_load.test.tsx @@ -99,3 +99,28 @@ test('triggers click event on URL change', () => { screen.getByText('Mayor'); expect(mockOnClick).toHaveBeenCalled(); }); + +test('clears any pre-existing focus first', () => { + const { mockOnClick, renderWithClickListener } = newRenderer(); + + mockOnClick.mockImplementation((event: MouseEvent) => { + assert(event.target instanceof HTMLElement); + expect(event.target.textContent).toEqual('Akwaaba!'); + }); + + const previouslyFocusedElement = document.createElement('button'); + document.body.appendChild(previouslyFocusedElement); + previouslyFocusedElement.focus(); + + const activeElementBlurSpy = jest.spyOn(previouslyFocusedElement, 'blur'); + + renderWithClickListener( + + Akwaaba! + + ); + + screen.getByText('Akwaaba!'); + expect(activeElementBlurSpy).toHaveBeenCalled(); + expect(mockOnClick).toHaveBeenCalled(); +}); diff --git a/libs/ui/src/ui_strings/read_on_load.tsx b/libs/ui/src/ui_strings/read_on_load.tsx index d0ddc2c9c..23f9e62e5 100644 --- a/libs/ui/src/ui_strings/read_on_load.tsx +++ b/libs/ui/src/ui_strings/read_on_load.tsx @@ -50,6 +50,19 @@ export function ReadOnLoad(props: ReadOnLoadProps): JSX.Element { return; } + // Clear pre-existing active focus first, if any. + // Avoids any controls that trigger the render of a `ReadOnLoad` component + // holding focus and preventing the following `focus`/`click` events from + // working (e.g. persistent page navigation buttons). + const { activeElement } = document; + if ( + activeElement instanceof HTMLElement || + /* istanbul ignore next */ + activeElement instanceof SVGElement + ) { + activeElement.blur(); + } + containerRef.current.focus(); containerRef.current.click(); }, [currentUrl, isInAudioContext]);