Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ From this screen, you can directly access the Ballot Locator prefilled to lookup
<li>You can copy the Ballot ID and manually enter it in the Ballot Locator Screen at a later stage. See the explanation for this in Option 2.</li>
</ul>

When available for your election, the **Copy Ballot ID** button appears next to the ID on both the Review and Confirmation screens. It copies the complete ID, even when the displayed ID is shortened. If the copy button is hidden on Review, it is also hidden on Confirmation.

:::tip
**Alternative:** You can also click in the `Print` button and the same options will be available but originating from the PDF you downloaded instead.
:::
Expand Down
125 changes: 118 additions & 7 deletions packages/ui-essentials/src/components/BallotHash/BallotHash.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
/** @jest-environment jsdom */
// SPDX-FileCopyrightText: 2026 Sequent Tech Inc <legal@sequentech.io>
//
// SPDX-License-Identifier: AGPL-3.0-only

import React from "react"
import {renderToStaticMarkup} from "react-dom/server"
import {act, fireEvent, render, screen} from "@testing-library/react"
import "@testing-library/jest-dom"
import {ThemeProvider} from "@mui/material/styles"
import BallotHash, {copyBallotHash, CopyBallotHashStatus} from "./BallotHash"
import theme from "../../services/theme"

jest.mock("../LinkBehavior/LinkBehavior", () => "a")

jest.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, values?: {ballotId: string}): string =>
Expand Down Expand Up @@ -40,8 +44,115 @@ describe("copyBallotHash", () => {
})

describe("BallotHash", () => {
afterEach(() => {
jest.useRealTimers()
Reflect.deleteProperty(navigator, "clipboard")
})

it.each([":hover", ":active"])("keeps the copy button's box model unchanged on %s", (state) => {
render(
<ThemeProvider theme={theme}>
<BallotHash hash="abc123" copyLabels={copyLabels} />
</ThemeProvider>
)
const button = screen.getByRole("button", {name: copyLabels.copy})
const restingStyle = getComputedStyle(button)
const interactionStyle = document.createElement("button").style
interactionStyle.padding = restingStyle.padding
interactionStyle.border = restingStyle.border
let matchedRules = 0

// jsdom does not apply pointer pseudo-classes; resolve the matching
// Emotion rules against the button's resting box model instead.
for (const sheet of Array.from(document.styleSheets)) {
for (const cssRule of Array.from(sheet.cssRules)) {
const rule = cssRule as CSSStyleRule
if (
rule.type === CSSRule.STYLE_RULE &&
rule.selectorText.includes(state) &&
button.matches(rule.selectorText.replaceAll(state, ""))
) {
matchedRules += 1
for (let index = 0; index < rule.style.length; index++) {
const property = rule.style[index]
interactionStyle.setProperty(
property,
rule.style.getPropertyValue(property)
)
}
}
}
}

expect(matchedRules).toBeGreaterThan(0)
expect(interactionStyle.paddingBottom).toBe(restingStyle.paddingBottom)
expect(interactionStyle.borderWidth).toBe(restingStyle.borderWidth)
})

it.each([undefined, copyLabels])("has no copy control for an empty hash (%j)", (labels) => {
render(
<ThemeProvider theme={theme}>
<BallotHash hash="" copyLabels={labels} />
</ThemeProvider>
)
expect(screen.queryByRole("button", {name: copyLabels.copy})).toBeNull()
})

it("keeps copying opt-in even when there is a hash", () => {
render(
<ThemeProvider theme={theme}>
<BallotHash hash="abc123" />
</ThemeProvider>
)
expect(screen.queryByRole("button", {name: copyLabels.copy})).toBeNull()
})

it("announces a complete copy and resets feedback after two seconds or a hash change", async () => {
jest.useFakeTimers()
const writeText = jest.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, "clipboard", {configurable: true, value: {writeText}})
const view = render(
<ThemeProvider theme={theme}>
<BallotHash hash="abc123" copyLabels={copyLabels} />
</ThemeProvider>
)

await act(async () => fireEvent.click(screen.getByRole("button", {name: copyLabels.copy})))
expect(writeText).toHaveBeenCalledWith("abc123")
expect(screen.getByRole("status")).toHaveTextContent(copyLabels.copied)
expect(screen.getByRole("button", {name: copyLabels.copied})).toBeInTheDocument()
act(() => jest.advanceTimersByTime(2000))
expect(screen.getByRole("status")).toBeEmptyDOMElement()

await act(async () => fireEvent.click(screen.getByRole("button", {name: copyLabels.copy})))
view.rerender(
<ThemeProvider theme={theme}>
<BallotHash hash="different-hash" copyLabels={copyLabels} />
</ThemeProvider>
)
expect(screen.getByRole("status")).toBeEmptyDOMElement()
expect(screen.getByRole("button", {name: copyLabels.copy})).toBeInTheDocument()
})

it.each([undefined, {writeText: jest.fn().mockRejectedValue(new Error("denied"))}])(
"announces clipboard failures (%j)",
async (clipboard) => {
Object.defineProperty(navigator, "clipboard", {configurable: true, value: clipboard})
render(
<ThemeProvider theme={theme}>
<BallotHash hash="abc123" copyLabels={copyLabels} />
</ThemeProvider>
)
await act(async () =>
fireEvent.click(screen.getByRole("button", {name: copyLabels.copy}))
)
expect(screen.getByRole("status")).toHaveTextContent(copyLabels.error)
expect(screen.getByRole("button", {name: copyLabels.error})).toBeInTheDocument()
}
)

it("renders the optional copy control with an accessible name", () => {
const markup = renderToStaticMarkup(
render(
<ThemeProvider theme={theme}>
<BallotHash
hash="abc123"
Expand All @@ -51,18 +162,18 @@ describe("BallotHash", () => {
</ThemeProvider>
)

expect(markup).toContain('aria-label="Copy ballot ID"')
expect(markup).toContain('aria-label="About ballot ID"')
expect(markup).toContain('role="status"')
expect(screen.getByRole("button", {name: "Copy ballot ID"})).toBeInTheDocument()
expect(screen.getByRole("button", {name: "About ballot ID"})).toBeInTheDocument()
expect(screen.getByRole("status")).toBeInTheDocument()
})

it("uses the translated fallback when no help label is supplied", () => {
const markup = renderToStaticMarkup(
render(
<ThemeProvider theme={theme}>
<BallotHash hash="abc123" />
</ThemeProvider>
)

expect(markup).toContain('aria-label="About your Ballot ID"')
expect(screen.getByRole("button", {name: "About your Ballot ID"})).toBeInTheDocument()
})
})
58 changes: 37 additions & 21 deletions packages/ui-essentials/src/components/BallotHash/BallotHash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,10 @@ export const copyBallotHash = async (
}
}

const BallotHash: React.FC<BallotHashProps> = ({
export const BallotHashCopyButton: React.FC<Pick<BallotHashProps, "hash" | "copyLabels">> = ({
hash,
onHelpClick,
helpButtonLabel,
copyLabels,
}) => {
const {t} = useTranslation()
const [copyStatus, setCopyStatus] = useState(CopyBallotHashStatus.Idle)

useEffect(() => setCopyStatus(CopyBallotHashStatus.Idle), [hash])
Expand All @@ -126,6 +123,41 @@ const BallotHash: React.FC<BallotHashProps> = ({
const copyStatusLabel =
copyLabels?.[copyStatus === CopyBallotHashStatus.Idle ? "copy" : copyStatus]

if (!copyLabels || !hash) {
return null
}

return (
<>
<IconButton
icon={COPY_ICON[copyStatus]}
title={copyStatusLabel}
sx={{
"fontSize": "unset",
"lineHeight": "unset",
"paddingBottom": "2px",
"color": theme.palette.customGrey.contrastText,
"&:hover": {paddingBottom: "2px"},
"&:active": {border: "2px solid transparent"},
}}
fontSize="18px"
onClick={handleCopy}
/>
<CopyStatus role="status" aria-live="polite" aria-atomic="true">
{copyStatus === CopyBallotHashStatus.Idle ? "" : copyStatusLabel}
</CopyStatus>
</>
)
}

const BallotHash: React.FC<BallotHashProps> = ({
hash,
onHelpClick,
helpButtonLabel,
copyLabels,
}) => {
const {t} = useTranslation()

return (
<HashContainer className="hash-container">
<DecorativeIconBox className="hash-check">
Expand All @@ -138,20 +170,7 @@ const BallotHash: React.FC<BallotHashProps> = ({
{t("ballotHash", {ballotId: hash})}
</BallotHashText>
<HashActions>
{copyLabels && hash ? (
<IconButton
icon={COPY_ICON[copyStatus]}
title={copyStatusLabel}
sx={{
fontSize: "unset",
lineHeight: "unset",
paddingBottom: "2px",
color: theme.palette.customGrey.contrastText,
}}
fontSize="18px"
onClick={handleCopy}
/>
) : null}
<BallotHashCopyButton hash={hash} copyLabels={copyLabels} />
<IconButton
icon={faCircleQuestion}
title={helpButtonLabel}
Expand All @@ -166,9 +185,6 @@ const BallotHash: React.FC<BallotHashProps> = ({
ariaLabel={helpButtonLabel || t("a11y.ballotIdHelp")}
/>
</HashActions>
<CopyStatus role="status" aria-live="polite" aria-atomic="true">
{copyStatus === CopyBallotHashStatus.Idle ? "" : copyStatusLabel}
</CopyStatus>
</HashContainer>
)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/ui-essentials/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export {
} from "./components/BreadCrumbSteps/BreadCrumbSteps"
export {default as Candidate} from "./components/Candidate/Candidate"
export {getOrdinalSuffix} from "./components/Candidate/ordinalUtils"
export {default as BallotHash} from "./components/BallotHash/BallotHash"
export {default as BallotHash, BallotHashCopyButton} from "./components/BallotHash/BallotHash"
export {default as QRCode} from "./components/QRCode/QRCode"
export {default as CandidatesList} from "./components/CandidatesList/CandidatesList"
export {default as SelectElection} from "./components/SelectElection/SelectElection"
Expand Down
73 changes: 73 additions & 0 deletions packages/ui-essentials/src/services/theme.accessibility.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/** @jest-environment jsdom */
// SPDX-FileCopyrightText: 2026 Sequent Tech Inc <legal@sequentech.io>
//
// SPDX-License-Identifier: AGPL-3.0-only

import React from "react"
import {act, fireEvent, render, screen} from "@testing-library/react"
import "@testing-library/jest-dom"
import {Checkbox, ThemeProvider, getContrastRatio} from "@mui/material"
import {faCircleQuestion} from "@fortawesome/free-solid-svg-icons"
import IconButton from "../components/IconButton/IconButton"
import theme from "./theme"

jest.mock("../components/LinkBehavior/LinkBehavior", () => "a")

const focusWithKeyboard = async (element: HTMLElement) => {
fireEvent.keyDown(document, {key: "Tab"})
await act(async () => element.focus())
}

describe("shared control contrast", () => {
it.each([false, true])("keeps an enabled checkbox discernible (checked: %s)", (checked) => {
render(
<ThemeProvider theme={theme}>
<Checkbox checked={checked} slotProps={{input: {"aria-label": "Declaration"}}} />
</ThemeProvider>
)
const control = screen.getByRole("checkbox").closest(".MuiCheckbox-root")!
const color = getComputedStyle(control).color

for (const background of ["#ffffff", theme.palette.lightBackground]) {
expect(getContrastRatio(color, background)).toBeGreaterThanOrEqual(3)
}
})

it("keeps a persistent two-tone focus ring while a checkbox toggles", async () => {
render(
<ThemeProvider theme={theme}>
<Checkbox slotProps={{input: {"aria-label": "Declaration"}}} disableRipple />
</ThemeProvider>
)
const checkbox = screen.getByRole("checkbox")
const control = checkbox.closest(".MuiCheckbox-root")!
await focusWithKeyboard(checkbox)

expect(control).toHaveClass("Mui-focusVisible")
expect(control).toHaveStyle({
outline: "2px solid black",
outlineOffset: "2px",
boxShadow: "0 0 0 2px white",
})
fireEvent.click(checkbox)
expect(checkbox).toBeChecked()
expect(control).toHaveStyle({outline: "2px solid black", boxShadow: "0 0 0 2px white"})
})

it("gives the shared help button a focus ring independent of its icon color", async () => {
render(
<ThemeProvider theme={theme}>
<IconButton icon={faCircleQuestion} ariaLabel="Help" sx={{color: "#b8c0cc"}} />
</ThemeProvider>
)
const help = screen.getByRole("button", {name: "Help"})
await focusWithKeyboard(help)

expect(help).toHaveClass("Mui-focusVisible")
expect(help).toHaveStyle({
outline: "2px solid black",
outlineOffset: "2px",
boxShadow: "0 0 0 2px white",
})
})
})
11 changes: 10 additions & 1 deletion packages/ui-essentials/src/services/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,12 +602,20 @@ let MuiDialog: Components["MuiDialog"] = {
},
}

// A two-tone ring stays visible on light and dark tenant backgrounds.
const keyboardFocusStyle = {
outline: `2px solid ${palette.black}`,
outlineOffset: "2px",
boxShadow: `0 0 0 2px ${palette.white}`,
}

let MuiIconButton: Components["MuiIconButton"] = {
styleOverrides: {
root: {
"padding": 0,
"border": `2px solid transparent`,
"color": palette.black,
"&.Mui-focusVisible": keyboardFocusStyle,
"&:hover": {
padding: 0,
filter: "drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25))",
Expand Down Expand Up @@ -637,7 +645,8 @@ let MuiTextField: Components["MuiTextField"] = {
let MuiCheckbox: Components["MuiCheckbox"] = {
styleOverrides: {
root: {
"color": palette.extraGrey.main,
"color": palette.customGrey.main,
"&.Mui-focusVisible": keyboardFocusStyle,
"&:hover": {
backgroundColor: "unset",
},
Expand Down
Loading
Loading