diff --git a/cypress/e2e/state-calculator.cy.ts b/cypress/e2e/state-calculator.cy.ts index a0d17aa..8cb2e29 100644 --- a/cypress/e2e/state-calculator.cy.ts +++ b/cypress/e2e/state-calculator.cy.ts @@ -9,6 +9,11 @@ describe('template spec', () => { .shadow() .contains('Your household info'); + cy.get('rewiring-america-state-calculator') + .shadow() + .find('sl-select#projects') + .invoke('attr', 'value', 'hvac'); + cy.get('rewiring-america-state-calculator') .shadow() .find('input#zip') @@ -21,7 +26,7 @@ describe('template spec', () => { cy.get('rewiring-america-state-calculator') .shadow() .find('select#utility') - .should("exist"); + .should('exist'); cy.get('rewiring-america-state-calculator') .shadow() @@ -49,6 +54,6 @@ describe('template spec', () => { cy.get('rewiring-america-state-calculator') .shadow() - .contains("Other incentives available to you"); + .contains('Other incentives available to you'); }); }); diff --git a/src/calculator-form.ts b/src/calculator-form.ts index 7914540..494f2de 100644 --- a/src/calculator-form.ts +++ b/src/calculator-form.ts @@ -1,6 +1,6 @@ import { html, css, nothing } from 'lit'; import { downIcon, questionIcon } from './icons'; -import { select, selectStyles, OptionParam } from './select'; +import { select, multiselect, selectStyles, OptionParam } from './select'; import { inputStyles } from './styles/input'; import './currency-input'; import '@shoelace-style/shoelace/dist/themes/light.css'; @@ -75,35 +75,30 @@ const HOUSEHOLD_SIZE_OPTIONS: OptionParam[] = [1, 2, 3, 4, 5, 6, 7, 8].map( ); export const formTemplate = ( - [ - project, - zip, - ownerStatus, - householdIncome, - taxFiling, - householdSize, - ]: Array, + [zip, ownerStatus, householdIncome, taxFiling, householdSize]: Array, + projects: Array, showProjectField: boolean, onSubmit: (e: SubmitEvent) => void, gridClass: string = 'grid-3-2', ) => { const projectField = showProjectField ? html`
-
` diff --git a/src/calculator.ts b/src/calculator.ts index e8d3c87..2c62ed1 100644 --- a/src/calculator.ts +++ b/src/calculator.ts @@ -12,7 +12,6 @@ import { } from './calculator-types'; import { CALCULATOR_FOOTER } from './calculator-footer'; import { fetchApi } from './api/fetch'; -import { NO_PROJECT } from './projects'; const loadedTemplate = ( results: ICalculatedIncentiveResults, @@ -151,13 +150,13 @@ export class RewiringAmericaCalculator extends LitElement { ? nothing : formTemplate( [ - NO_PROJECT, this.zip, this.ownerStatus, this.householdIncome, this.taxFiling, this.householdSize, ], + [], false, (event: SubmitEvent) => this.submit(event), )} diff --git a/src/select.ts b/src/select.ts index 41d55ac..d71df37 100644 --- a/src/select.ts +++ b/src/select.ts @@ -1,5 +1,8 @@ import { html, css } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; +import '@shoelace-style/shoelace/dist/themes/light.css'; +import '@shoelace-style/shoelace/dist/components/option/option.js'; +import '@shoelace-style/shoelace/dist/components/select/select.js'; export interface OptionParam { label: string; @@ -17,9 +20,27 @@ export interface SelectParam { disabled?: boolean; } +export interface SLSelectParam { + id: string; + label?: string; + options: OptionParam[]; + helpText?: string; + placeholder?: string; + placement?: string; + required?: boolean; +} + +export interface MultiSelectParam extends SLSelectParam { + currentValues: string[]; + maxOptionsVisible?: number; +} + export const option = ({ label, value }: OptionParam, selected: boolean) => html` `; +export const multioption = ({ label, value }: OptionParam) => + html` ${label} `; + export const select = ({ id, required, @@ -48,6 +69,38 @@ export const select = ({ `; }; +export const multiselect = ({ + id, + label, + currentValues, + options, + helpText, + placeholder, + maxOptionsVisible, + placement, +}: MultiSelectParam) => { + return html` +
+ + + ${options.map(o => multioption(o))} + + +
+ `; +}; + export const selectStyles = css` /* // @link https://moderncss.dev/custom-select-styles-with-pure-css/ */ @@ -174,4 +227,28 @@ export const selectStyles = css` background-color: #eee; background-image: linear-gradient(to top, #ddd, #eee 33%); } + + sl-select { + --sl-input-height-medium: 2.8215rem; + + --sl-input-font-family: var(--ra-embed-font-family); + + --sl-input-focus-ring-color: var(--select-focus); + --sl-input-focus-ring-style: solid; + --sl-focus-ring-width: 1px; + + --sl-input-border-width: 1px; + --sl-input-border-color-focus: var(--select-focus); + + margin-top: 4px; + } + + sl-select::part(expand-icon) { + content: ''; + justify-self: end; + width: 0.6em; + height: 0.4em; + background-color: var(--select-arrow); + clip-path: polygon(100% 0%, 0 0%, 50% 100%); + } `; diff --git a/src/state-calculator.ts b/src/state-calculator.ts index 8112ee8..a09bce3 100644 --- a/src/state-calculator.ts +++ b/src/state-calculator.ts @@ -91,11 +91,14 @@ export class RewiringAmericaStateCalculator extends LitElement { @property({ type: String }) utility: string = ''; + @property({ type: Array }) + projects: Project[] = []; + @property({ type: String }) - selectedProject: Project = 'hvac'; + selectedProjectTab: Project | undefined; @property({ type: String }) - selectedOtherTab: Project = 'heat_pump_clothes_dryer'; + selectedOtherTab: Project | undefined; /** * This is a hack to deal with a quirk of the UI. @@ -124,7 +127,7 @@ export class RewiringAmericaStateCalculator extends LitElement { this.householdIncome = (formData.get('household_income') as string) || ''; this.taxFiling = (formData.get('tax_filing') as FilingStatus) || ''; this.householdSize = (formData.get('household_size') as string) || ''; - this.selectedProject = (formData.get('project') as Project) || ''; + this.projects = (formData.getAll('projects') as Project[]) || ''; // Zip is the only thing that determines what utilities are available, so // only fetch utilities if zip has changed since last calculation, or if @@ -140,6 +143,33 @@ export class RewiringAmericaStateCalculator extends LitElement { } } + override async firstUpdated() { + // Give the browser a chance to paint + await new Promise(r => setTimeout(r, 0)); + const select = this.renderRoot.querySelector('sl-select'); + const combobox = this.renderRoot + .querySelector('sl-select') + ?.renderRoot.querySelector('div.select__combobox'); + + select?.addEventListener('keydown', event => { + if (event.key === 'Tab' && select.open) { + event.preventDefault(); + event.stopPropagation(); + select.hide(); + select.displayInput.focus({ preventScroll: true }); + } + }); + + combobox?.addEventListener('keydown', event => { + if (event.key === 'Tab' && select?.open) { + event.preventDefault(); + event.stopPropagation(); + select.hide(); + select.displayInput.focus({ preventScroll: true }); + } + }); + } + isFormComplete() { return !!( this.zip && @@ -147,7 +177,7 @@ export class RewiringAmericaStateCalculator extends LitElement { this.taxFiling && this.householdIncome && this.householdSize && - this.selectedProject + this.projects ); } @@ -253,13 +283,13 @@ export class RewiringAmericaStateCalculator extends LitElement { ? nothing : formTemplate( [ - this.selectedProject, this.zip, this.ownerStatus, this.householdIncome, this.taxFiling, this.householdSize, ], + this.projects, true, (event: SubmitEvent) => this.submit(event), 'grid-3-2-1', @@ -292,9 +322,12 @@ export class RewiringAmericaStateCalculator extends LitElement { html`
`, stateIncentivesTemplate( this._task.value!, - this.selectedProject, + this.projects, + newOtherSelection => + (this.selectedOtherTab = newOtherSelection), + newSelection => (this.selectedProjectTab = newSelection), this.selectedOtherTab, - newSelection => (this.selectedOtherTab = newSelection), + this.selectedProjectTab, ), ] : nothing} diff --git a/src/state-incentive-details.ts b/src/state-incentive-details.ts index 87917de..96ff2ba 100644 --- a/src/state-incentive-details.ts +++ b/src/state-incentive-details.ts @@ -369,7 +369,7 @@ const gridTemplate = ( selectedTab: Project, onTabSelected: (newSelection: Project) => void, ) => - incentives.length > 0 + tabs.length > 0 && incentives.length > 0 ? html`

${heading}

@@ -392,9 +392,11 @@ const gridTemplate = ( */ export const stateIncentivesTemplate = ( response: APIResponse, - selectedProject: Project, - selectedOtherTab: Project, + selectedProjects: Project[], + onOtherTabSelected: (newOtherSelection: Project) => void, onTabSelected: (newSelection: Project) => void, + selectedOtherTab?: Project, + selectedProjectTab?: Project, ) => { const allEligible = response.incentives.filter(i => i.eligible); @@ -405,32 +407,57 @@ export const stateIncentivesTemplate = ( ]), ) as Record; + const nonSelectedProjects = Object.entries(PROJECTS) + .filter(([project, _]) => !selectedProjects.includes(project as Project)) + .sort(([a], [b]) => shortLabel(a).localeCompare(shortLabel(b))) + .map(([project, _]) => project); + // Only offer "other" tabs if there are incentives for that project. const otherTabs = ( Object.entries(incentivesByProject) as [Project, Incentive[]][] ) .filter( ([project, incentives]) => - project !== selectedProject && incentives.length > 0, + !selectedProjects.includes(project) && incentives.length > 0, ) .sort(([a], [b]) => shortLabel(a).localeCompare(shortLabel(b))) .map(([project]) => project); + const projectTab = + selectedProjectTab && + selectedProjects.includes(selectedProjectTab as Project) + ? selectedProjectTab + : selectedProjects[0]; + const otherTab = + selectedOtherTab && + nonSelectedProjects.includes(selectedOtherTab as Project) + ? selectedOtherTab + : nonSelectedProjects[0]; + + const selectedIncentives = incentivesByProject[projectTab] ?? []; + const selectedOtherIncentives = + incentivesByProject[otherTab as Project] ?? []; + + const otherIncentivesLabel = + selectedIncentives.length == 0 + ? 'Incentives available to you' + : 'Other incentives available to you'; + return html` ${atAGlanceTemplate(response)} ${gridTemplate( "Incentives you're interested in", - incentivesByProject[selectedProject] ?? [], - [selectedProject], - selectedProject, - () => {}, + selectedIncentives, + selectedProjects, + projectTab, + onTabSelected, )} ${gridTemplate( - 'Other incentives available to you', - incentivesByProject[selectedOtherTab] ?? [], + otherIncentivesLabel, + selectedOtherIncentives, otherTabs, // If a nonexistent tab is selected, pretend the first one is selected. - otherTabs.includes(selectedOtherTab) ? selectedOtherTab : otherTabs[0], - onTabSelected, + otherTab as Project, + onOtherTabSelected, )} ${authorityLogosTemplate(response)}`; };