Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

va-checkbox: add indeterminate prop #1426

Merged
merged 11 commits into from
Dec 10, 2024
99 changes: 99 additions & 0 deletions packages/storybook/stories/va-checkbox-uswds.stories.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -26,6 +29,7 @@ const defaultArgs = {
'hint': null,
'tile': false,
'message-aria-describedby': 'Optional description text for screen readers',
'indeterminate': false,
};

const vaCheckbox = args => {
Expand All @@ -40,6 +44,7 @@ const vaCheckbox = args => {
hint,
tile,
'message-aria-describedby': messageAriaDescribedBy,
indeterminate,
...rest
} = args;
return (
Expand All @@ -55,6 +60,7 @@ const vaCheckbox = args => {
tile={tile}
onBlur={e => console.log(e)}
message-aria-describedby={messageAriaDescribedBy}
indeterminate={indeterminate}
/>
);
};
Expand Down Expand Up @@ -90,6 +96,96 @@ 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 (
<fieldset>
<legend className="vads-u-font-size--md vads-u-margin-bottom--3">Indeterminate Checkbox Example</legend>
<VaCheckbox
class="indeterminate-checkbox"
label="All Historical Figures"
indeterminate
onVaChange={e => handleSelectAllToggle(e)}
/>

<hr className="vads-u-margin-y--2" />

<VaCheckbox
class="example-checkbox"
id="checkbox-1"
checked={checked[0]}
data-index={0}
label="Sojourner Truth"
onVaChange={e => handleCheckboxChange(e)}
/>

<VaCheckbox
class="example-checkbox"
id="checkbox-2"
checked={checked[1]}
data-index={1}
label="George Washington Carver"
onVaChange={e => handleCheckboxChange(e)}
/>

<VaCheckbox
class="example-checkbox"
id="checkbox-3"
checked={checked[2]}
data-index={2}
label="Frederick Douglass"
onVaChange={e => handleCheckboxChange(e)}
/>
</fieldset>
);
};

export const Default = Template.bind(null);
Default.args = { ...defaultArgs };
Default.argTypes = propStructure(checkboxDocs);
Expand Down Expand Up @@ -159,3 +255,6 @@ Internationalization.args = {
error: 'There has been a problem',
required: true,
};

export const Indeterminate = IndeterminateTemplate.bind(null);
Indeterminate.args = { ...defaultArgs };
8 changes: 8 additions & 0 deletions packages/web-components/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Copy link
Contributor Author

@jamigibbs jamigibbs Dec 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried also to get a test working that checked if the input element was getting the .indeterminate property from being set in JS but I could not get it to work. :/

This is kind of a strange property though so either it's some kind of testing limitation with Stencil or I'm just not writing the test in a way that it can pick up that value.

const page = await newE2EPage();
await page.setContent(
'<va-checkbox indeterminate label="Just another checkbox here" />',
);
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(
'<va-checkbox indeterminate label="Just another checkbox here" />',
);
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(
'<va-checkbox indeterminate checked label="Just another checkbox here" />',
);
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(
'<va-checkbox indeterminate label="Just another checkbox here" />',
);
await axeCheck(page, ['aria-allowed-role']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -154,6 +159,26 @@ export class VaCheckbox {
if (this.enableAnalytics) this.fireAnalyticsEvent();
};

/**
* For a11y, input.indeterminate must be set with JavaScript, there is no HTML attribute for this.
*/
private updateIndeterminateInput() {
const input = this.el.shadowRoot.querySelector('input');
if (this.indeterminate && !this.checked) {
input.indeterminate = true;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are adding this indeterminate attribute manually with JavaScript based on Ryan's a11y findings here. When I tested with VO, I confirmed that this is what announces "mixed" as the checkbox state when it's active.

} else {
input.indeterminate = false;
}
}

componentDidUpdate() {
this.updateIndeterminateInput();
}

componentDidLoad() {
this.updateIndeterminateInput();
}

connectedCallback() {
i18next.on('languageChanged', () => {
forceUpdate(this.el);
Expand All @@ -176,7 +201,8 @@ export class VaCheckbox {
checkboxDescription,
disabled,
messageAriaDescribedby,
name
name,
indeterminate,
} = this;
const hasDescriptionSlot =
!description &&
Expand Down Expand Up @@ -230,14 +256,15 @@ export class VaCheckbox {
aria-describedby={ariaDescribedbyIds}
aria-invalid={error ? 'true' : 'false'}
disabled={disabled}
data-indeterminate={indeterminate && !checked}
Copy link
Contributor Author

@jamigibbs jamigibbs Dec 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The USWDS release notes say:

when you set input.indeterminate = true via JavaScript or add the data-indeterminate attribute

...but I couldn't get everything working for both the styles and screenreader without adding both indeterminate using JS and also adding this data-indeterminate attribute. Maybe this is a shadow dom limitation?

onChange={this.handleChange}
/>
<label
htmlFor="checkbox-element"
class="usa-checkbox__label"
part="label"
role="checkbox"
aria-checked={ariaChecked}
aria-checked={indeterminate && !checked ? 'mixed' : ariaChecked}
Copy link
Contributor Author

@jamigibbs jamigibbs Dec 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm adding this because otherwise the aria-checked state would be either true or false but it also accepts "mixed" according to MDN.

Setting indeterminate on the input element though seems to be what is making the screenreader announcement. I'm not sure exactly what aria-checked is doing in this scenario if anything.

>
{label}&nbsp;
{required && (
Expand Down
Loading