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 new file mode 100644 index 000000000..8295f3691 --- /dev/null +++ b/src/js/components/Ballot/OfficeInfoModal.jsx @@ -0,0 +1,416 @@ +/* eslint-disable react/jsx-one-expression-per-line */ +/* eslint-disable react/no-unescaped-entities */ +/* eslint-disable react/jsx-closing-tag-location */ +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 + {' '} + <br className="mobile-break" /> + <strong>{officeName}</strong> + + + + + + + + + + + View this ballot section in new tab + + + + Copy ballot section page link + + + + + + + + ); + + const textFieldJSX = ( +
+ + + + + + + + + + + Watch video + (25 seconds) + + + + + + +

+ {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. +

+

+ 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 {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. +

+ {/* eslint-enable react/jsx-indent */} +
+
+
+
+ ); + + return ( + <> + + + + + + ); +}; + +OfficeInfoModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + officeName: PropTypes.string.isRequired, +}; + +// Global Styles +const WidenOfficeInfoModal = createGlobalStyle` + .MuiDialog-paper:has(#closeModalDisplayTemplateAofficeInfoModal) { + max-width: 750px !important; + width: 96% !important; + } +`; + +const SoftenCorners = createGlobalStyle` + .MuiDialog-paper:has(#closeModalDisplayTemplateAofficeInfoModal) { + border-radius: 14px !important; + } +`; + +const RemoveTitlePadding = createGlobalStyle` + .MuiDialogTitle-root:has(#closeModalDisplayTemplateAofficeInfoModal) > div > div { + padding-left: 0 !important; + width: 100% !important; + } +`; + +// Styled Components +const HeaderRow = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + width: 100%; + padding-left: 16px; + padding-bottom: 8px; +`; + +const Title = styled.h3` + 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}; + padding: 20px; +`; + +const FlexContainer = styled.div` + display: flex; + gap: 24px; + align-items: flex-start; + + @media (max-width: 768px) { + flex-direction: column; + gap: 20px; + } +`; + +const LeftColumn = styled.div` + flex: 0 0 200px; + + @media (max-width: 768px) { + flex: 1; + width: 100%; + } +`; + +const RightColumn = styled.div` + flex: 1; + + p { + 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; + } +`; + +const VideoPlaceholder = styled.div` + width: 100%; + 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; + gap: 10px; + margin-top: 10px; + cursor: pointer; + + @media (max-width: 768px) { + margin-top: 0; + flex: 1; + } +`; + +const PlayButtonWrapper = styled.div` + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + + &:hover svg rect { + fill: #06437D; + } +`; + +const VideoTextWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +`; + +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 DurationText = styled.span` + font-size: 13px; + color: #666666; + font-weight: 400; + line-height: 1.2; + white-space: nowrap; + + @media (max-width: 768px) { + font-size: 12px; + } +`; + +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...
}> + + + )}
); }