-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add modal glossary of payment methods
## Description There's a lot going on here, so here's the high-level tour: - The glossary modal itself looks (I think) exactly as designed. I've made a few edits to the copy compared to what's in the Figma; I've put comments in the Figma to explain why. - Most of the logic of displaying a modal dialog is hand-written, though I added third-party packages to trap focus within it and stop the body from scrolling while it's up. I did try using Headless UI's `Dialog` component, which does all that out of the box, but (like the rest of Headless!) it's unusable with shadow DOM. It renders the dialog at the root of `document`, where it does not get any of the shadow DOM's styling; there seems to be no way to change this. - The entire incentive-type chip is now a button. (The alternative was to make only the icon the button, which makes for an uncomfortably small click target.) This required a bit of refactoring of the incentive card component. ### Accessibility - Keyboard navigation: - When you activate one of the chip buttons, focus automatically goes to the section header of the appropriate section. (The entire header is an expand/collapse button, not just the circled chevron.) - The modal traps focus while it's up. - Hitting Escape while the modal is up will dismiss it. - When the modal closes, focus returns to the chip button that caused it to open. - Screenreader experience: I think it's OK? I am not an expert screen reader user by any means. It's possible a few more things could use ARIA roles. One judgment call I made is to aria-label the question icon in the chips as "show glossary". When a chip is focused, the reader says "Tax credit show glossary, button". It seemed good to have a clue as to what will happen if you click the button? ### Next steps If this all seems suitable, I'll request translations. ## Test Plan - Look at the component on all widths, in several browsers (tried Chrome, Firefox, Safari, MobileSafari). Make sure the section corresponding to the incentive type you clicked on is open as soon as the modal appears, and no others are open. Click on others to open and close. Open all the sections and make sure the scrolling is correct. - Make sure the modal is dismissible by clicking away from the body, clicking the X button, or hitting Escape. - Scroll-wheel with the cursor away from the modal body, and make sure the document doesn't scroll underneath. - Navigate with keyboard: tab to the incentive-type chip and hit Enter. Make sure focus is immediately on the right section header. Tab and shift-tab and make sure focus is trapped within the modal. Hit Esc, or "enter" on the X button, and make sure focus is back on the original chip. - Do all of the above with VoiceOver on.
- Loading branch information
Showing
10 changed files
with
551 additions
and
29 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,280 @@ | ||
import { Disclosure } from '@headlessui/react'; | ||
import clsx from 'clsx'; | ||
import FocusTrap from 'focus-trap-react'; | ||
import { useEffect, useId, useRef } from 'react'; | ||
import { lock, unlock } from 'tua-body-scroll-lock'; | ||
import { IncentiveType } from './api/calculator-types-v1'; | ||
import { useTranslated } from './i18n/use-translated'; | ||
import { CircledChevron, Cross } from './icons'; | ||
|
||
/** A single accordion section. */ | ||
function Section({ | ||
title, | ||
paragraphs, | ||
defaultExpanded, | ||
}: { | ||
title: string; | ||
paragraphs: string[]; | ||
defaultExpanded?: boolean; | ||
}) { | ||
return ( | ||
<Disclosure | ||
as="div" | ||
defaultOpen={defaultExpanded} | ||
className={clsx( | ||
'group', | ||
'flex', | ||
'flex-col', | ||
'py-4', | ||
'gap-4', | ||
'border-b', | ||
'last:border-b-0', | ||
'border-color-border-secondary', | ||
)} | ||
> | ||
<Disclosure.Button | ||
autoFocus={defaultExpanded} | ||
className="flex items-center text-left" | ||
> | ||
<h2 className="grow text-lg font-medium leading-tight">{title}</h2> | ||
<span className="group-data-open:rotate-180"> | ||
<CircledChevron | ||
w={40} | ||
h={40} | ||
fillClass="fill-[#ebebeb] group-data-open:fill-color-action-primary" | ||
strokeClass="stroke-grey-900 group-data-open:stroke-color-text-primary-on-dark" | ||
/> | ||
</span> | ||
</Disclosure.Button> | ||
<Disclosure.Panel className="flex flex-col gap-4"> | ||
<p className="font-medium leading-normal">{paragraphs[0]}</p> | ||
{paragraphs.slice(1).map((text, index) => ( | ||
<p key={title + index} className="leading-normal"> | ||
{text} | ||
</p> | ||
))} | ||
</Disclosure.Panel> | ||
</Disclosure> | ||
); | ||
} | ||
|
||
export function Glossary({ | ||
expandedSection, | ||
onClose, | ||
}: { | ||
expandedSection: IncentiveType; | ||
onClose: () => void; | ||
}) { | ||
const { msg } = useTranslated(); | ||
|
||
const sections: { key: IncentiveType; title: string; body: string[] }[] = [ | ||
{ | ||
key: 'account_credit', | ||
title: msg('Account credit'), | ||
body: [ | ||
msg( | ||
`An account credit is a credit applied to the total amount owed on a \ | ||
utility account bill.`, | ||
), | ||
msg( | ||
`Typically, you receive an account credit after meeting income \ | ||
eligibility or participating in an energy saving program.`, | ||
), | ||
msg( | ||
`Your utility will apply the credit to your billing account, which \ | ||
will reduce the amount you owe on your next bill.`, | ||
), | ||
], | ||
}, | ||
{ | ||
key: 'assistance_program', | ||
title: msg('Assistance program'), | ||
body: [ | ||
msg( | ||
`An assistance program is a program funded by government agencies, \ | ||
utility companies, or non-profit organizations that provides products or \ | ||
services like EV chargers and weatherization for free.`, | ||
), | ||
msg( | ||
`Typically, you are eligible to receive assistance based on income \ | ||
level, household size, and energy usage.`, | ||
), | ||
msg( | ||
`You may receive this assistance directly in the form of a home \ | ||
service or appliance installation, or you may receive a voucher or coupon to \ | ||
complete the purchase yourself.`, | ||
), | ||
], | ||
}, | ||
{ | ||
key: 'performance_rebate', | ||
title: msg('Performance rebate'), | ||
body: [ | ||
msg( | ||
`A post-purchase rebate that depends on measured or modeled \ | ||
efficiency improvements.`, | ||
), | ||
], | ||
}, | ||
{ | ||
key: 'rebate', | ||
title: msg('Rebate (post-purchase)'), | ||
body: [ | ||
msg( | ||
`A post-purchase rebate reduces the overall cost of a product by \ | ||
providing a partial or full reimbursement after the purchase.`, | ||
), | ||
msg( | ||
`You will receive this rebate directly in the form of a \ | ||
reimbursement, either as a check or a refund to the original payment method.`, | ||
), | ||
], | ||
}, | ||
{ | ||
key: 'tax_credit', | ||
title: msg('Tax credit'), | ||
body: [ | ||
msg( | ||
`A tax credit reduces the amount of tax you owe, or increases your \ | ||
refund amount.`, | ||
), | ||
msg( | ||
`Typically, claiming a tax credit requires purchasing and installing \ | ||
eligible energy-efficient products or making eligible home improvements in a \ | ||
specific timeframe.`, | ||
), | ||
msg('You will claim tax credits when filing your tax returns.'), | ||
], | ||
}, | ||
{ | ||
key: 'pos_rebate', | ||
title: msg('Upfront discount'), | ||
body: [ | ||
msg( | ||
`An upfront discount, or point-of-sale rebate, reduces the overall \ | ||
cost of a product by providing an instant discount at the time of purchase.`, | ||
), | ||
msg( | ||
`Typically, these rebates will apply to products you can purchase on \ | ||
your own, like an induction stove or EV charger.`, | ||
), | ||
msg( | ||
`You will receive this rebate directly in the form of a discount \ | ||
applied when you purchase the product in-person or online.`, | ||
), | ||
], | ||
}, | ||
]; | ||
|
||
// Sort by title post-localization | ||
sections.sort((a, b) => a.title.localeCompare(b.title)); | ||
|
||
const titleId = useId(); | ||
const scrollableDivRef = useRef<HTMLDivElement>(null); | ||
|
||
// Stop the main document from scrolling while the modal is up | ||
useEffect(() => { | ||
lock([scrollableDivRef.current!]); | ||
return () => unlock(scrollableDivRef.current!); | ||
}); | ||
|
||
return ( | ||
<FocusTrap | ||
focusTrapOptions={{ | ||
// The appropriate disclosure button will focus itself on mount, by way | ||
// of its autoFocus prop. | ||
initialFocus: false, | ||
// This functionality doesn't play nice with shadow DOM. Parent must | ||
// handle this itself. | ||
returnFocusOnDeactivate: false, | ||
}} | ||
> | ||
<div | ||
className={clsx( | ||
'fixed', | ||
'z-10', | ||
'top-0', | ||
'left-0', | ||
'w-full', | ||
'h-full', | ||
'flex', | ||
'items-center', | ||
'justify-center', | ||
'backdrop-blur-sm', | ||
'bg-purple-100/80', | ||
)} | ||
// Clicking off the body of the modal, or hitting Escape, closes it | ||
onClick={onClose} | ||
onKeyDown={e => { | ||
// "Escape" is standard; some older browsers use "Esc" | ||
if (e.key === 'Escape' || e.key === 'Esc') { | ||
onClose(); | ||
} | ||
}} | ||
> | ||
<div | ||
className={clsx( | ||
'flex', | ||
'flex-col', | ||
'w-full', | ||
'h-full', | ||
'sm:max-w-2xl', | ||
'sm:h-5/6', | ||
'sm:rounded-xl', | ||
'bg-color-background-primary', | ||
'shadow-modal', | ||
'overflow-hidden', | ||
)} | ||
// Prevent clicks on the body from closing the modal | ||
onClick={e => e.stopPropagation()} | ||
role="dialog" | ||
aria-labelledby={titleId} | ||
aria-modal={true} | ||
> | ||
<div | ||
className={clsx( | ||
'flex', | ||
'p-6', | ||
'shadow-modalHeader', | ||
'text-color-text-primary', | ||
'text-lg', | ||
'font-bold', | ||
'leading-tight', | ||
)} | ||
> | ||
<h2 id={titleId} className="grow"> | ||
{msg('Savings Programs Glossary')} | ||
</h2> | ||
<button | ||
type="button" | ||
aria-label={msg('Close glossary', { desc: 'button caption' })} | ||
onClick={onClose} | ||
> | ||
<Cross w={20} h={20} /> | ||
</button> | ||
</div> | ||
<div | ||
ref={scrollableDivRef} | ||
className="h-full overflow-y-auto p-6 text-color-text-primary" | ||
> | ||
<p className="leading-normal"> | ||
{msg( | ||
`There are tons of savings programs that help people purchase \ | ||
electric appliances and reduce their energy costs. This glossary explains how \ | ||
different programs work!`, | ||
)} | ||
</p> | ||
{sections.map(({ key, title, body }) => ( | ||
<Section | ||
key={key} | ||
title={title} | ||
paragraphs={body} | ||
defaultExpanded={key === expandedSection} | ||
/> | ||
))} | ||
</div> | ||
</div> | ||
</div> | ||
</FocusTrap> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.