Skip to content

Commit

Permalink
Add modal glossary of payment methods
Browse files Browse the repository at this point in the history
## 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
oyamauchi committed Sep 9, 2024
1 parent d0a7226 commit c84a7c5
Show file tree
Hide file tree
Showing 10 changed files with 551 additions and 29 deletions.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
"@shoelace-style/shoelace": "^2.12.0",
"autonumeric": "^4.8.1",
"clsx": "^2.1.0",
"focus-trap-react": "^10.2.3",
"lit": "^2.6.1",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"scroll-into-view-if-needed": "^3.1.0"
"scroll-into-view-if-needed": "^3.1.0",
"tua-body-scroll-lock": "^1.5.0"
},
"scripts": {
"build:widget": "parcel build --target default",
Expand Down
1 change: 1 addition & 0 deletions src/api/calculator-types-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type IncentiveType =
| 'pos_rebate'
| 'rebate'
| 'account_credit'
| 'assistance_program'
| 'performance_rebate';
export type AuthorityType =
| 'federal'
Expand Down
280 changes: 280 additions & 0 deletions src/glossary.tsx
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>
);
}
21 changes: 21 additions & 0 deletions src/i18n/strings/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,25 @@ export const templates = {
sfa7338035e1ef173: `Alquilar o poseer`,
sfc7214f623fe475d: `Selecciona la empresa a la que paga su factura de electricidad.`,
sfe16afc784bb9d76: `Techo solar`,
s21d732f30093580e: `An account credit is a credit applied to the total amount owed on a utility account bill.`,
sf8aa7c1ad930864e: `Typically, you receive an account credit after meeting income eligibility or participating in an energy saving program.`,
s7903680222950710: `Your utility will apply the credit to your billing account, which will reduce the amount you owe on your next bill.`,
sda26e62d22ed7245: `Assistance program`,
s70a1610e40612f81: `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.`,
sd31986cdb3a8bcbd: `Typically, you are eligible to receive assistance based on income level, household size, and energy usage.`,
se646cd89781702c9: `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.`,
sce99871785d95461: `A post-purchase rebate that depends on measured or modeled efficiency improvements.`,
s67a5270759e957c7: `Rebate (post-purchase)`,
sc35d11d8267deec3: `A post-purchase rebate reduces the overall cost of a product by providing a partial or full reimbursement after the purchase.`,
scd7dd59edbdcea8e: `You will receive this rebate directly in the form of a reimbursement, either as a check or a refund to the original payment method.`,
s0741effe7b99c7bd: `A tax credit reduces the amount of tax you owe, or increases your refund amount.`,
s3316ac17ffe1b283: `Typically, claiming a tax credit requires purchasing and installing eligible energy-efficient products or making eligible home improvements in a specific timeframe.`,
s6485e8c3d8bc0cef: `You will claim tax credits when filing your tax returns.`,
s8405d4e662b5fe5f: `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.`,
sb47273967c96111a: `Typically, these rebates will apply to products you can purchase on your own, like an induction stove or EV charger.`,
s0a9dcc7e3dd02eef: `You will receive this rebate directly in the form of a discount applied when you purchase the product in-person or online.`,
s785a04ea0278a9f3: `Savings Programs Glossary`,
s1065e0aff37235c1: `Close glossary`,
scc927054cd8d396c: `There are tons of savings programs that help people purchase electric appliances and reduce their energy costs. This glossary explains how different programs work!`,
sa29805436437d2bc: `Show glossary`,
};
Loading

0 comments on commit c84a7c5

Please sign in to comment.