From 3cfc64318b572a478449d2dfde41f4e8c8bbf2f8 Mon Sep 17 00:00:00 2001 From: Jami Gibbs Date: Tue, 10 Dec 2024 16:37:26 -0600 Subject: [PATCH] va-checkbox: add indeterminate prop (#1426) * add indeterminate prop * add indeterminate storybook example * update example * add tests * set indeterminate with JS on input element * cleanup * cleanup * add link to code example to storybook * add condition check for clearing indeterminate * update story checkbox label --- .../stories/va-checkbox-uswds.stories.jsx | 105 ++++++++++++++++++ packages/web-components/src/components.d.ts | 8 ++ .../va-checkbox/test/va-checkbox.e2e.ts | 35 ++++++ .../components/va-checkbox/va-checkbox.tsx | 32 +++++- 4 files changed, 178 insertions(+), 2 deletions(-) diff --git a/packages/storybook/stories/va-checkbox-uswds.stories.jsx b/packages/storybook/stories/va-checkbox-uswds.stories.jsx index 16148e4b2b..afc9d9b043 100644 --- a/packages/storybook/stories/va-checkbox-uswds.stories.jsx +++ b/packages/storybook/stories/va-checkbox-uswds.stories.jsx @@ -2,6 +2,9 @@ /* eslint-disable react/no-unescaped-entities */ import React, { useState, useEffect } from 'react'; import { getWebComponentDocs, propStructure, StoryDocs } from './wc-helpers'; +import { VaCheckbox } from '@department-of-veterans-affairs/web-components/react-bindings'; + +VaCheckbox.displayName = 'VaCheckbox'; const checkboxDocs = getWebComponentDocs('va-checkbox'); @@ -26,6 +29,7 @@ const defaultArgs = { 'hint': null, 'tile': false, 'message-aria-describedby': 'Optional description text for screen readers', + 'indeterminate': false, }; const vaCheckbox = args => { @@ -40,6 +44,7 @@ const vaCheckbox = args => { hint, tile, 'message-aria-describedby': messageAriaDescribedBy, + indeterminate, ...rest } = args; return ( @@ -55,6 +60,7 @@ const vaCheckbox = args => { tile={tile} onBlur={e => console.log(e)} message-aria-describedby={messageAriaDescribedBy} + indeterminate={indeterminate} /> ); }; @@ -90,6 +96,102 @@ const I18nTemplate = args => { ); }; +const IndeterminateTemplate = ({}) => { + const [checked, setChecked] = useState([true, true, false]); + + useEffect(() => { + handleIndeterminate(); + }, [checked]); + + const handleIndeterminate = () => { + const indeterminateCheckbox = document.querySelector('.indeterminate-checkbox'); + + // If all of the checkbox states are true, set indeterminate checkbox to checked. + if (checked.every(val => val === true)) { + indeterminateCheckbox.checked = true; + indeterminateCheckbox.indeterminate = false; + // If any one of the checkbox states is true, set indeterminate checkbox to indeterminate. + } else if (checked.some(val => val === true)) { + indeterminateCheckbox.checked = false; + indeterminateCheckbox.indeterminate = true; + // Otherwise, reset the indeterminate checkbox to unchecked. + } else { + indeterminateCheckbox.checked = false; + indeterminateCheckbox.indeterminate = false + } + }; + + const handleCheckboxChange = event => { + const index = parseInt(event.target.getAttribute('data-index')); + const nextChecked = checked.map((value, i) => { + if (i === index) { + return event.detail.checked; + } else { + return value; + } + }); + setChecked(nextChecked); + } + + const handleSelectAllToggle = event => { + const checkboxes = document.querySelectorAll('.example-checkbox'); + checkboxes.forEach(checkbox => { + checkbox.checked = event.target.checked; + }); + + // toggle state of all checkboxes to match the "select all" checkbox + const nextChecked = checked.map(() => event.target.checked); + setChecked(nextChecked) + } + + return ( + <> +
+ Indeterminate Checkbox Example + handleSelectAllToggle(e)} + /> + +
+ + handleCheckboxChange(e)} + /> + + handleCheckboxChange(e)} + /> + + handleCheckboxChange(e)} + /> +
+ + + ); +}; + export const Default = Template.bind(null); Default.args = { ...defaultArgs }; Default.argTypes = propStructure(checkboxDocs); @@ -159,3 +261,6 @@ Internationalization.args = { error: 'There has been a problem', required: true, }; + +export const Indeterminate = IndeterminateTemplate.bind(null); +Indeterminate.args = { ...defaultArgs }; diff --git a/packages/web-components/src/components.d.ts b/packages/web-components/src/components.d.ts index e6a0774c7d..27292fb0ed 100644 --- a/packages/web-components/src/components.d.ts +++ b/packages/web-components/src/components.d.ts @@ -366,6 +366,10 @@ export namespace Components { * Optional hint text. */ "hint"?: string; + /** + * When true, the checkbox can be toggled between checked and indeterminate states. + */ + "indeterminate"?: boolean; /** * The label for the checkbox. */ @@ -3563,6 +3567,10 @@ declare namespace LocalJSX { * Optional hint text. */ "hint"?: string; + /** + * When true, the checkbox can be toggled between checked and indeterminate states. + */ + "indeterminate"?: boolean; /** * The label for the checkbox. */ diff --git a/packages/web-components/src/components/va-checkbox/test/va-checkbox.e2e.ts b/packages/web-components/src/components/va-checkbox/test/va-checkbox.e2e.ts index 5286b59c1a..ae0c28ca1b 100644 --- a/packages/web-components/src/components/va-checkbox/test/va-checkbox.e2e.ts +++ b/packages/web-components/src/components/va-checkbox/test/va-checkbox.e2e.ts @@ -257,4 +257,39 @@ describe('va-checkbox', () => { await checkboxLabelEl.click(); expect(await checkboxEl.getProperty('checked')).toBeTruthy(); }); + + it('sets the data-indeterminate attribute on the input element', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + const checkboxEl = await page.find('va-checkbox >>> input'); + expect(checkboxEl).toHaveAttribute('data-indeterminate'); + }); + + it('sets aria-checked mixed for indeterminate state', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + const checkboxEl = await page.find('va-checkbox >>> label'); + expect(checkboxEl).toEqualAttribute('aria-checked', 'mixed'); + }); + + it('does not set the data-indeterminate attribute if checked is set', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + const checkboxEl = await page.find('va-checkbox >>> input'); + expect(checkboxEl).not.toHaveAttribute('data-indeterminate'); + }); + + it('passes aXe check when indeterminate prop is set', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + await axeCheck(page, ['aria-allowed-role']); + }); }); diff --git a/packages/web-components/src/components/va-checkbox/va-checkbox.tsx b/packages/web-components/src/components/va-checkbox/va-checkbox.tsx index 3df36bb4ef..68ab744adb 100644 --- a/packages/web-components/src/components/va-checkbox/va-checkbox.tsx +++ b/packages/web-components/src/components/va-checkbox/va-checkbox.tsx @@ -106,6 +106,11 @@ export class VaCheckbox { */ @Prop() name?: string; + /** + * When true, the checkbox can be toggled between checked and indeterminate states. + */ + @Prop() indeterminate?: boolean = false; + /** * The event used to track usage of the component. This is emitted when the * input value changes and enableAnalytics is true. @@ -154,6 +159,27 @@ export class VaCheckbox { if (this.enableAnalytics) this.fireAnalyticsEvent(); }; + /** + * Primarily for a11y; input.indeterminate must be set with JavaScript, + * there is no HTML attribute for this. + */ + private handleIndeterminateInput() { + const input = this.el.shadowRoot.querySelector('input'); + if (this.indeterminate && !this.checked) { + input.indeterminate = true; + } else { + input.indeterminate = false; + } + } + + componentDidUpdate() { + this.handleIndeterminateInput(); + } + + componentDidLoad() { + this.handleIndeterminateInput(); + } + connectedCallback() { i18next.on('languageChanged', () => { forceUpdate(this.el); @@ -176,7 +202,8 @@ export class VaCheckbox { checkboxDescription, disabled, messageAriaDescribedby, - name + name, + indeterminate, } = this; const hasDescriptionSlot = !description && @@ -230,6 +257,7 @@ export class VaCheckbox { aria-describedby={ariaDescribedbyIds} aria-invalid={error ? 'true' : 'false'} disabled={disabled} + data-indeterminate={indeterminate && !checked} onChange={this.handleChange} />