From b9861a537b41394d7d9971adbb94cca2b62bbcdf Mon Sep 17 00:00:00 2001 From: lyndabanh Date: Tue, 16 Dec 2025 15:15:53 -0500 Subject: [PATCH 1/2] Add info icon next to office name that opens modal * Add InfoOutlined icon with hover state * Create OfficeInfoModal component with flexbox layout * Implement lazy loading and modal state management * Apply stying and disable ESLint formatting rules --- src/js/components/Ballot/OfficeInfoModal.jsx | 192 ++++++++++++++++++ .../Ballot/OfficeItemCompressed.jsx | 39 +++- 2 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/js/components/Ballot/OfficeInfoModal.jsx diff --git a/src/js/components/Ballot/OfficeInfoModal.jsx b/src/js/components/Ballot/OfficeInfoModal.jsx new file mode 100644 index 000000000..4feea31b5 --- /dev/null +++ b/src/js/components/Ballot/OfficeInfoModal.jsx @@ -0,0 +1,192 @@ +/* eslint-disable react/jsx-one-expression-per-line */ +/* eslint-disable react/no-unescaped-entities */ +/* eslint-disable react/jsx-closing-tag-location */ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled, { createGlobalStyle } from 'styled-components'; +import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; +import ModalDisplayTemplateA from '../Widgets/ModalDisplayTemplateA'; + +const OfficeInfoModal = ({ isOpen, onClose, officeName }) => { + const dialogTitleJSX = ( + + + About the office of <strong>{officeName}</strong> + + + ); + + const textFieldJSX = ( +
+ + + + + +

Watch video
(25 seconds)

+
+
+ + +

+ An {officeName} is the chief legal officer of a state, + responsible for enforcing laws, protecting citizens' rights, and representing + the public interest in legal matters. +

+

+ They oversee investigations, defend state laws in court, and provide legal + guidance to government agencies. +

+ {/* eslint-disable react/jsx-indent */} +

+ Because they influence everything from consumer protections to civil rights + and election integrity, choosing an {officeName} in an election is + crucial—it determines who will uphold justice, safeguard democratic + processes, and hold powerful entities accountable on behalf of the public. +

+ {/* eslint-enable react/jsx-indent */} +
+
+
+ + + + Close + + +
+ ); + + return ( + <> + + + + + ); +}; + +OfficeInfoModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + officeName: PropTypes.string.isRequired, +}; + +// Global Styles +const WidenPreviewModal = createGlobalStyle` + .MuiDialog-paper:has(#closeModalDisplayTemplateAofficeInfoModal) { + max-width: 860px !important; + width: 96% !important; + } +`; + +const SoftenCorners = createGlobalStyle` + .MuiDialog-paper:has(#closeModalDisplayTemplateAofficeInfoModal) { + border-radius: 14px !important; + } +`; + +// Styles +const HeaderRow = styled.div` + padding: 0px 12px 0 18px; +`; + +const Title = styled.h3` + font-size: 28px; + font-weight: 400; +`; + +const ModalBody = styled.div` + background: ${DesignTokenColors.whiteUI}; + border: 1px solid ${DesignTokenColors.neutralUI200}; + border-radius: 10px; + padding: 14px; +`; + +// FLEXBOX LAYOUT +const FlexContainer = styled.div` + display: flex; + gap: 20px; + align-items: flex-start; + + /* Stack vertically on mobile */ + @media (max-width: 768px) { + flex-direction: column; + } +`; + +const LeftColumn = styled.div` + flex: 0 0 180px; + + @media (max-width: 768px) { + flex: 1; + width: 100%; + } +`; + +const RightColumn = styled.div` + flex: 1; + + p { + margin-bottom: 12px; + line-height: 1.6; + } +`; + +// Video placeholder +const VideoPlaceholder = styled.div` + width: 100%; + aspect-ratio: 4/3; + background: ${DesignTokenColors.neutral200}; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: ${DesignTokenColors.neutral700}; + text-align: center; + cursor: pointer; + + &:hover { + background: ${DesignTokenColors.neutral300}; + } + + p { + margin: 8px 0 0 0; + font-size: 14px; + } +`; + +const PlayIcon = styled.div` + font-size: 32px; + color: ${DesignTokenColors.primary700} +`; + +const ModalFooter = styled.div` + display: flex; + justify-content: flex-end; + margin-top: 12px; +`; + +const CloseButton = styled.button` + background: ${DesignTokenColors.primary700}; + border: 1px solid ${DesignTokenColors.primary700}; + border-radius: 9999px; + color: ${DesignTokenColors.whiteUI}; + cursor: pointer; + padding: 10px 18px; + + &:hover{ + background: ${DesignTokenColors.primary800}; + border-color: ${DesignTokenColors.primary800}; + } +`; + +export default OfficeInfoModal; + diff --git a/src/js/components/Ballot/OfficeItemCompressed.jsx b/src/js/components/Ballot/OfficeItemCompressed.jsx index a1831ab0f..9a7f8d1c4 100644 --- a/src/js/components/Ballot/OfficeItemCompressed.jsx +++ b/src/js/components/Ballot/OfficeItemCompressed.jsx @@ -1,8 +1,10 @@ +import { InfoOutlined } from '@mui/icons-material'; import withStyles from '@mui/styles/withStyles'; import withTheme from '@mui/styles/withTheme'; import PropTypes from 'prop-types'; import React, { Component, Suspense } from 'react'; import styled from 'styled-components'; +import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; import OfficeActions from '../../actions/OfficeActions'; import BallotScrollingContainer from './BallotScrollingContainer'; import { BallotScrollingOuterWrapper } from '../../common/components/Style/ScrollingStyles'; @@ -16,6 +18,7 @@ import { sortCandidateList } from '../../utils/positionFunctions'; import { OfficeItemCompressedWrapper, OfficeNameH2 } from '../Style/BallotStyles'; const ShowMoreButtons = React.lazy(() => import(/* webpackChunkName: 'ShowMoreButtons' */ '../Widgets/ShowMoreButtons')); +const OfficeInfoModal = React.lazy(() => import(/* webpackChunkName: 'OfficeInfoModal' */ './OfficeInfoModal')); const NUMBER_OF_CANDIDATES_TO_DISPLAY = 5; // This is related to components/VoterGuide/VoterGuideOfficeItemCompressed @@ -29,6 +32,8 @@ class OfficeItemCompressed extends Component { candidateListForDisplay: [], showAllCandidates: false, totalNumberOfCandidates: 0, + officeInfoModalOpen: false, + moreInfoIconHovered: false, }; } @@ -87,6 +92,22 @@ class OfficeItemCompressed extends Component { }); }; + openOfficeInfoModal = () => { + this.setState({ officeInfoModalOpen: true }); + }; + + closeOfficeInfoModal = () => { + this.setState({ officeInfoModalOpen: false }); + }; + + handleMoreInfoIconHover = () => { + this.setState({ moreInfoIconHovered: true }); + } + + handleMoreInfoIconLeave = () => { + this.setState({ moreInfoIconHovered: false }); + } + goToCandidateLink (candidateWeVoteId) { const { organizationWeVoteId } = this.props; const candidateLink = organizationWeVoteId ? @@ -101,10 +122,11 @@ class OfficeItemCompressed extends Component { let { ballotItemDisplayName } = this.props; const { isFirstBallotItem, officeWeVoteId, primaryParty, useHelpDefeatOrHelpWin } = this.props; // classes - const { candidateListLength, showAllCandidates, totalNumberOfCandidates } = this.state; + const { candidateListLength, showAllCandidates, totalNumberOfCandidates, moreInfoIconHovered } = this.state; ballotItemDisplayName = toTitleCase(ballotItemDisplayName).replace('(Unexpired)', '(Remainder)'); const moreCandidatesToDisplay = candidateListLength > NUMBER_OF_CANDIDATES_TO_DISPLAY; + return (
{ballotItemDisplayName} + {!!primaryParty && ( {' '} @@ -144,6 +172,15 @@ class OfficeItemCompressed extends Component { /> )} + {this.state.officeInfoModalOpen && ( + Loading...
}> + + + )}
); } From af849b2718103ff060ba5601d74be19f16482910 Mon Sep 17 00:00:00 2001 From: lyndabanh Date: Thu, 8 Jan 2026 15:01:31 -0500 Subject: [PATCH 2/2] Add three-dot menu and improve modal styling --- package-lock.json | 10 + package.json | 1 + src/js/components/Ballot/OfficeInfoModal.jsx | 360 +++++++++++++++---- 3 files changed, 303 insertions(+), 68 deletions(-) diff --git a/package-lock.json b/package-lock.json index ccb3c7014..9362d38e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "googleapis": "^163.0.0", "heic-to": "^1.1.14", "iframe-resizer-react": "1.1.0", + "indefinite": "^2.5.2", "jquery": "^3.7.1", "keymirror": "~0.1.1", "lodash-es": "^4.17.21", @@ -21377,6 +21378,15 @@ "node": ">=0.8.19" } }, + "node_modules/indefinite": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/indefinite/-/indefinite-2.5.2.tgz", + "integrity": "sha512-J3ELLIk835hmgDMUfNltTCrHz9+CteTnSuXJqvxZT18wo1U2M9/QeeJMw99QdZwPEEr1DE2aBYda101Ojjdw5g==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", diff --git a/package.json b/package.json index 386fe4db7..e2740c712 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "googleapis": "^163.0.0", "heic-to": "^1.1.14", "iframe-resizer-react": "1.1.0", + "indefinite": "^2.5.2", "jquery": "^3.7.1", "keymirror": "~0.1.1", "lodash-es": "^4.17.21", diff --git a/src/js/components/Ballot/OfficeInfoModal.jsx b/src/js/components/Ballot/OfficeInfoModal.jsx index 4feea31b5..8295f3691 100644 --- a/src/js/components/Ballot/OfficeInfoModal.jsx +++ b/src/js/components/Ballot/OfficeInfoModal.jsx @@ -1,18 +1,98 @@ /* eslint-disable react/jsx-one-expression-per-line */ /* eslint-disable react/no-unescaped-entities */ /* eslint-disable react/jsx-closing-tag-location */ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import styled, { createGlobalStyle } from 'styled-components'; +import indefinite from 'indefinite'; +import { MoreHoriz, LaunchOutlined, ContentCopy } from '@mui/icons-material'; +import Popover from '@mui/material/Popover'; import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; import ModalDisplayTemplateA from '../Widgets/ModalDisplayTemplateA'; +import { openSnackbar } from '../../common/components/Widgets/SnackNotifier'; const OfficeInfoModal = ({ isOpen, onClose, officeName }) => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const id = open ? 'office-info-popover' : undefined; + + const handleMenuClick = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handlePopoverClose = () => { + setAnchorEl(null); + }; + + const handleOpenNewTab = () => { + window.open(window.location.href, '_blank'); + handlePopoverClose(); + }; + + const handleCopyLink = () => { + navigator.clipboard.writeText(window.location.href).then(() => { + openSnackbar({ message: 'Link copied!', severity: 'success' }); + handlePopoverClose(); + }) + .catch((err) => { + console.error('Failed to copy link:', err); + openSnackbar({ message: 'Failed to copy link', severity: 'error' }); + handlePopoverClose(); + }); + }; + + const PlayButton = () => ( + + + + + ); + const dialogTitleJSX = ( - About the office of <strong>{officeName}</strong> + About the office of + {' '} + <br className="mobile-break" /> + <strong>{officeName}</strong> + + + + + + + + + + View this ballot section in new tab + + + + Copy ballot section page link + + + + + + ); @@ -21,15 +101,24 @@ const OfficeInfoModal = ({ isOpen, onClose, officeName }) => { - - -

Watch video
(25 seconds)

-
+ + + + + + + + Watch video + (25 seconds) + + +

- An {officeName} is the chief legal officer of a state, + {indefinite(officeName, { articleOnly: true, capitalize: true })}{' '} + {officeName} is the chief legal officer of a state, responsible for enforcing laws, protecting citizens' rights, and representing the public interest in legal matters.

@@ -40,7 +129,7 @@ const OfficeInfoModal = ({ isOpen, onClose, officeName }) => { {/* eslint-disable react/jsx-indent */}

Because they influence everything from consumer protections to civil rights - and election integrity, choosing an {officeName} in an election is + and election integrity, choosing {indefinite(officeName)} in an election is crucial—it determines who will uphold justice, safeguard democratic processes, and hold powerful entities accountable on behalf of the public.

@@ -48,26 +137,21 @@ const OfficeInfoModal = ({ isOpen, onClose, officeName }) => {
- - - - Close - - ); return ( <> - + + ); @@ -80,9 +164,9 @@ OfficeInfoModal.propTypes = { }; // Global Styles -const WidenPreviewModal = createGlobalStyle` +const WidenOfficeInfoModal = createGlobalStyle` .MuiDialog-paper:has(#closeModalDisplayTemplateAofficeInfoModal) { - max-width: 860px !important; + max-width: 750px !important; width: 96% !important; } `; @@ -93,37 +177,137 @@ const SoftenCorners = createGlobalStyle` } `; -// Styles +const RemoveTitlePadding = createGlobalStyle` + .MuiDialogTitle-root:has(#closeModalDisplayTemplateAofficeInfoModal) > div > div { + padding-left: 0 !important; + width: 100% !important; + } +`; + +// Styled Components const HeaderRow = styled.div` - padding: 0px 12px 0 18px; + display: flex; + justify-content: space-between; + align-items: flex-start; + width: 100%; + padding-left: 16px; + padding-bottom: 8px; `; const Title = styled.h3` - font-size: 28px; - font-weight: 400; + font-size: 18px; + font-weight: 400; + line-height: 1.3; + margin: 0; + flex: 1; + min-width: 0; + + strong { + font-weight: 600; + } + + .mobile-break { + display: none; + } + + @media (max-width: 768px) { + font-size: 16px; + + .mobile-break { + display: inline; + } + } +`; + +const HeaderActions = styled.div` + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + margin-right: 52px !important; + margin-top: -4px; + + @media (max-width: 768px) { + margin-right: 52px !important; + margin-top: -4px; + } +`; + +const VerticalLine = styled.div` + border-left: 1px solid ${DesignTokenColors.neutral200}; + height: 24px; + align-self: center; + + @media (max-width: 768px) { + height: 20px; + } +`; + +const TripleDotWrapper = styled.div` + color: ${DesignTokenColors.neutral600}; + display: flex; + align-items: center; + + &:hover { + color: ${DesignTokenColors.neutral400}; + cursor: pointer; + } +`; + +const TripleDotButton = styled.button` + background: transparent; + border: 0; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + color: inherit; + outline: none; + + &:focus { + outline: none; + } + + svg { + font-size: 20px; + } +`; + +const PopoverWrapper = styled.div` + padding: 8px 0; + min-width: 220px; +`; + +const PopoverOption = styled.div` + padding: 10px 16px; + font-size: 14px; + cursor: pointer; + color: ${DesignTokenColors.neutral900}; + white-space: nowrap; + + &:hover { + background: ${DesignTokenColors.neutral100}; + } `; const ModalBody = styled.div` - background: ${DesignTokenColors.whiteUI}; - border: 1px solid ${DesignTokenColors.neutralUI200}; - border-radius: 10px; - padding: 14px; + background: ${DesignTokenColors.whiteUI}; + padding: 20px; `; -// FLEXBOX LAYOUT const FlexContainer = styled.div` display: flex; - gap: 20px; + gap: 24px; align-items: flex-start; - - /* Stack vertically on mobile */ + @media (max-width: 768px) { flex-direction: column; + gap: 20px; } `; const LeftColumn = styled.div` - flex: 0 0 180px; + flex: 0 0 200px; @media (max-width: 768px) { flex: 1; @@ -135,58 +319,98 @@ const RightColumn = styled.div` flex: 1; p { - margin-bottom: 12px; + margin-bottom: 16px; line-height: 1.6; + font-size: 15px; + color: ${DesignTokenColors.neutral900}; + + &:last-child { + margin-bottom: 0; + } + } +`; + +const VideoContainer = styled.div` + display: flex; + flex-direction: column; + + @media (max-width: 768px) { + background: #ebebeb; + border-radius: 6px; + padding: 12px; + flex-direction: row; + align-items: center; + gap: 14px; } `; -// Video placeholder const VideoPlaceholder = styled.div` width: 100%; - aspect-ratio: 4/3; - background: ${DesignTokenColors.neutral200}; - border-radius: 8px; + aspect-ratio: 1 / 1; + background: #a0a0a0; + border-radius: 4px; + + @media (max-width: 768px) { + width: 90px; + height: 90px; + flex-shrink: 0; + } +`; + +const VideoControls = styled.div` display: flex; align-items: center; - justify-content: center; - color: ${DesignTokenColors.neutral700}; - text-align: center; + gap: 10px; + margin-top: 10px; cursor: pointer; - &:hover { - background: ${DesignTokenColors.neutral300}; + @media (max-width: 768px) { + margin-top: 0; + flex: 1; } +`; - p { - margin: 8px 0 0 0; - font-size: 14px; +const PlayButtonWrapper = styled.div` + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + + &:hover svg rect { + fill: #06437D; } `; -const PlayIcon = styled.div` - font-size: 32px; - color: ${DesignTokenColors.primary700} +const VideoTextWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; `; -const ModalFooter = styled.div` - display: flex; - justify-content: flex-end; - margin-top: 12px; +const WatchText = styled.span` + font-size: 16px; + color: #0858A1; + font-weight: 400; + line-height: 1.2; + white-space: nowrap; + + @media (max-width: 768px) { + font-size: 14px; + } `; -const CloseButton = styled.button` - background: ${DesignTokenColors.primary700}; - border: 1px solid ${DesignTokenColors.primary700}; - border-radius: 9999px; - color: ${DesignTokenColors.whiteUI}; - cursor: pointer; - padding: 10px 18px; +const DurationText = styled.span` + font-size: 13px; + color: #666666; + font-weight: 400; + line-height: 1.2; + white-space: nowrap; - &:hover{ - background: ${DesignTokenColors.primary800}; - border-color: ${DesignTokenColors.primary800}; - } + @media (max-width: 768px) { + font-size: 12px; + } `; export default OfficeInfoModal; -