feat(checkbox): add helperText and errorText properties (#30140)

Issue number: resolves #29810

---------

## What is the current behavior?
Checkbox does not support helper and error text.

## What is the new behavior?
Adds support for helper and error text, similar to input and textarea.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

## Other information

- [Bottom Content:
Preview](https://ionic-framework-git-rou-11141-ionic1.vercel.app/src/components/checkbox/test/bottom-content)
- [Item:
Preview](https://ionic-framework-git-rou-11141-ionic1.vercel.app/src/components/checkbox/test/item)

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
This commit is contained in:
Brandy Smith
2025-03-10 18:25:28 -04:00
committed by GitHub
parent 335672d523
commit 99d2f1c750
71 changed files with 582 additions and 34 deletions

View File

@ -148,44 +148,51 @@ input {
opacity: 0;
}
// Justify Content
// ---------------------------------------------
// Checkbox Bottom Content
// ----------------------------------------------------------------
.checkbox-bottom {
@include padding(4px, null, null, null);
display: flex;
:host(.checkbox-justify-space-between) .checkbox-wrapper {
justify-content: space-between;
font-size: dynamic-font(12px);
white-space: normal;
}
:host(.checkbox-justify-start) .checkbox-wrapper {
justify-content: start;
:host(.checkbox-label-placement-stacked) .checkbox-bottom {
font-size: dynamic-font(16px);
}
:host(.checkbox-justify-end) .checkbox-wrapper {
justify-content: end;
// Checkbox Hint Text
// ----------------------------------------------------------------
/**
* Error text should only be shown when .ion-invalid is
* present on the checkbox. Otherwise the helper text should
* be shown.
*/
.checkbox-bottom .error-text {
display: none;
color: ion-color(danger, base);
}
// Align Items
// ---------------------------------------------
:host(.checkbox-alignment-start) .checkbox-wrapper {
align-items: start;
}
:host(.checkbox-alignment-center) .checkbox-wrapper {
align-items: center;
}
// Justify Content & Align Items
// ---------------------------------------------
// The checkbox should be displayed as block when either justify
// or alignment is set; otherwise, these properties will have no
// visible effect.
:host(.checkbox-justify-space-between),
:host(.checkbox-justify-start),
:host(.checkbox-justify-end),
:host(.checkbox-alignment-start),
:host(.checkbox-alignment-center) {
.checkbox-bottom .helper-text {
display: block;
color: $text-color-step-300;
}
:host(.ion-touched.ion-invalid) .checkbox-bottom .error-text {
display: block;
}
:host(.ion-touched.ion-invalid) .checkbox-bottom .helper-text {
display: none;
}
// Label Placement - Start
@ -217,6 +224,8 @@ input {
*/
:host(.checkbox-label-placement-end) .checkbox-wrapper {
flex-direction: row-reverse;
justify-content: start;
}
/**
@ -260,6 +269,8 @@ input {
*/
:host(.checkbox-label-placement-stacked) .checkbox-wrapper {
flex-direction: column;
text-align: center;
}
:host(.checkbox-label-placement-stacked) .label-text-wrapper {
@ -287,6 +298,46 @@ input {
@include transform-origin(center, top);
}
// Justify Content
// ---------------------------------------------
:host(.checkbox-justify-space-between) .checkbox-wrapper {
justify-content: space-between;
}
:host(.checkbox-justify-start) .checkbox-wrapper {
justify-content: start;
}
:host(.checkbox-justify-end) .checkbox-wrapper {
justify-content: end;
}
// Align Items
// ---------------------------------------------
:host(.checkbox-alignment-start) .checkbox-wrapper {
align-items: start;
}
:host(.checkbox-alignment-center) .checkbox-wrapper {
align-items: center;
}
// Justify Content & Align Items
// ---------------------------------------------
// The checkbox should be displayed as block when either justify
// or alignment is set; otherwise, these properties will have no
// visible effect.
:host(.checkbox-justify-space-between),
:host(.checkbox-justify-start),
:host(.checkbox-justify-end),
:host(.checkbox-alignment-start),
:host(.checkbox-alignment-center) {
display: block;
}
// Checked / Indeterminate Checkbox
// ---------------------------------------------

View File

@ -17,6 +17,9 @@ import type { CheckboxChangeEventDetail } from './checkbox-interface';
* @part container - The container for the checkbox mark.
* @part label - The label text describing the checkbox.
* @part mark - The checkmark used to indicate the checked state.
* @part supporting-text - Supporting text displayed beneath the checkbox label.
* @part helper-text - Supporting text displayed beneath the checkbox label when the checkbox is valid.
* @part error-text - Supporting text displayed beneath the checkbox label when the checkbox is invalid and touched.
*/
@Component({
tag: 'ion-checkbox',
@ -28,6 +31,8 @@ import type { CheckboxChangeEventDetail } from './checkbox-interface';
})
export class Checkbox implements ComponentInterface {
private inputId = `ion-cb-${checkboxIds++}`;
private helperTextId = `${this.inputId}-helper-text`;
private errorTextId = `${this.inputId}-error-text`;
private focusEl?: HTMLElement;
private inheritedAttributes: Attributes = {};
@ -60,6 +65,16 @@ export class Checkbox implements ComponentInterface {
*/
@Prop() disabled = false;
/**
* Text that is placed under the checkbox label and displayed when an error is detected.
*/
@Prop() errorText?: string;
/**
* Text that is placed under the checkbox label and displayed when no error is detected.
*/
@Prop() helperText?: string;
/**
* The value of the checkbox does not mean if it's checked or not, use the `checked`
* property for that.
@ -174,6 +189,48 @@ export class Checkbox implements ComponentInterface {
this.toggleChecked(ev);
};
private getHintTextID(): string | undefined {
const { el, helperText, errorText, helperTextId, errorTextId } = this;
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
return errorTextId;
}
if (helperText) {
return helperTextId;
}
return undefined;
}
/**
* Responsible for rendering helper text and error text.
* This element should only be rendered if hint text is set.
*/
private renderHintText() {
const { helperText, errorText, helperTextId, errorTextId } = this;
/**
* undefined and empty string values should
* be treated as not having helper/error text.
*/
const hasHintText = !!helperText || !!errorText;
if (!hasHintText) {
return;
}
return (
<div class="checkbox-bottom">
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
{helperText}
</div>
<div id={errorTextId} class="error-text" part="supporting-text error-text">
{errorText}
</div>
</div>
);
}
render() {
const {
color,
@ -199,6 +256,8 @@ export class Checkbox implements ComponentInterface {
return (
<Host
aria-checked={indeterminate ? 'mixed' : `${checked}`}
aria-describedby={this.getHintTextID()}
aria-invalid={this.getHintTextID() === this.errorTextId}
class={createColorClasses(color, {
[mode]: true,
'in-item': hostContext('ion-item', el),
@ -237,6 +296,7 @@ export class Checkbox implements ComponentInterface {
part="label"
>
<slot></slot>
{this.renderHintText()}
</div>
<div class="native-wrapper">
<svg class="checkbox-icon" viewBox="0 0 24 24" part="container">

View File

@ -0,0 +1,207 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* Functionality is the same across modes & directions
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('checkbox: bottom content functionality'), () => {
test('should not render bottom content if no hint is enabled', async ({ page }) => {
await page.setContent(`<ion-checkbox>Label</ion-checkbox>`, config);
const bottomEl = page.locator('ion-checkbox .checkbox-bottom');
await expect(bottomEl).toHaveCount(0);
});
test('helper text should be visible initially', async ({ page }) => {
await page.setContent(
`<ion-checkbox helper-text="Helper text" error-text="Error text">Label</ion-checkbox>`,
config
);
const helperText = page.locator('ion-checkbox .helper-text');
const errorText = page.locator('ion-checkbox .error-text');
await expect(helperText).toBeVisible();
await expect(helperText).toHaveText('Helper text');
await expect(errorText).toBeHidden();
});
test('checkbox should have an aria-describedby attribute when helper text is present', async ({ page }) => {
await page.setContent(
`<ion-checkbox helper-text="Helper text" error-text="Error text">Label</ion-checkbox>`,
config
);
const checkbox = page.locator('ion-checkbox');
const helperText = page.locator('ion-checkbox .helper-text');
const helperTextId = await helperText.getAttribute('id');
const ariaDescribedBy = await checkbox.getAttribute('aria-describedby');
expect(ariaDescribedBy).toBe(helperTextId);
});
test('error text should be visible when checkbox is invalid', async ({ page }) => {
await page.setContent(
`<ion-checkbox class="ion-invalid ion-touched" helper-text="Helper text" error-text="Error text">Label</ion-checkbox>`,
config
);
const helperText = page.locator('ion-checkbox .helper-text');
const errorText = page.locator('ion-checkbox .error-text');
await expect(helperText).toBeHidden();
await expect(errorText).toBeVisible();
await expect(errorText).toHaveText('Error text');
});
test('checkbox should have an aria-describedby attribute when error text is present', async ({ page }) => {
await page.setContent(
`<ion-checkbox class="ion-invalid ion-touched" helper-text="Helper text" error-text="Error text">Label</ion-checkbox>`,
config
);
const checkbox = page.locator('ion-checkbox');
const errorText = page.locator('ion-checkbox .error-text');
const errorTextId = await errorText.getAttribute('id');
const ariaDescribedBy = await checkbox.getAttribute('aria-describedby');
expect(ariaDescribedBy).toBe(errorTextId);
});
test('checkbox should have aria-invalid attribute when checkbox is invalid', async ({ page }) => {
await page.setContent(
`<ion-checkbox class="ion-invalid ion-touched" helper-text="Helper text" error-text="Error text">Label</ion-checkbox>`,
config
);
const checkbox = page.locator('ion-checkbox');
await expect(checkbox).toHaveAttribute('aria-invalid');
});
test('checkbox should not have aria-invalid attribute when checkbox is valid', async ({ page }) => {
await page.setContent(
`<ion-checkbox helper-text="Helper text" error-text="Error text">Label</ion-checkbox>`,
config
);
const checkbox = page.locator('ion-checkbox');
await expect(checkbox).not.toHaveAttribute('aria-invalid');
});
test('checkbox should not have aria-describedby attribute when no hint or error text is present', async ({
page,
}) => {
await page.setContent(`<ion-checkbox>Label</ion-checkbox>`, config);
const checkbox = page.locator('ion-checkbox');
await expect(checkbox).not.toHaveAttribute('aria-describedby');
});
});
});
/**
* Rendering is different across modes
*/
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('checkbox: helper text rendering'), () => {
// Check the default label placement, end, and stacked
[undefined, 'end', 'stacked'].forEach((labelPlacement) => {
test(`${
labelPlacement ? `${labelPlacement} label - ` : ''
}should not have visual regressions when rendering helper text`, async ({ page }) => {
await page.setContent(
`<ion-checkbox ${
labelPlacement ? `label-placement="${labelPlacement}"` : ''
} helper-text="Helper text">Label</ion-checkbox>`,
config
);
const bottomEl = page.locator('ion-checkbox');
await expect(bottomEl).toHaveScreenshot(
screenshot(`checkbox-helper-text${labelPlacement ? `-${labelPlacement}` : ''}`)
);
});
test(`${
labelPlacement ? `${labelPlacement} label - ` : ''
}should not have visual regressions when rendering helper text with wrapping text`, async ({ page }) => {
await page.setContent(
`<ion-checkbox ${
labelPlacement ? `label-placement="${labelPlacement}"` : ''
} helper-text="Helper text helper text helper text helper text helper text helper text helper text helper text helper text">Label</ion-checkbox>`,
config
);
const bottomEl = page.locator('ion-checkbox');
await expect(bottomEl).toHaveScreenshot(
screenshot(`checkbox-helper-text${labelPlacement ? `-${labelPlacement}` : ''}-wrapping`)
);
});
});
});
test.describe(title('checkbox: error text rendering'), () => {
test('should not have visual regressions when rendering error text', async ({ page }) => {
await page.setContent(
`<ion-checkbox class="ion-invalid ion-touched" error-text="Error text">Label</ion-checkbox>`,
config
);
const bottomEl = page.locator('ion-checkbox');
await expect(bottomEl).toHaveScreenshot(screenshot(`checkbox-error-text`));
});
test('should not have visual regressions when rendering error text with a stacked label', async ({ page }) => {
await page.setContent(
`<ion-checkbox class="ion-invalid ion-touched" error-text="Error text" label-placement="stacked">Label</ion-checkbox>`,
config
);
const bottomEl = page.locator('ion-checkbox');
await expect(bottomEl).toHaveScreenshot(screenshot(`checkbox-error-text-stacked-label`));
});
});
});
/**
* Customizing supporting text is the same across modes and directions
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('checkbox: supporting text customization'), () => {
test('should not have visual regressions when rendering helper text with custom css', async ({ page }) => {
await page.setContent(
`
<style>
ion-checkbox::part(supporting-text) {
font-size: 20px;
}
ion-checkbox::part(helper-text) {
color: green;
}
</style>
<ion-checkbox helper-text="Helper text">Label</ion-checkbox>
`,
config
);
const helperText = page.locator('ion-checkbox');
await expect(helperText).toHaveScreenshot(screenshot(`checkbox-helper-text-custom-css`));
});
test('should not have visual regressions when rendering error text with custom css', async ({ page }) => {
await page.setContent(
`
<style>
ion-checkbox::part(supporting-text) {
font-size: 20px;
}
ion-checkbox::part(error-text) {
color: purple;
}
</style>
<ion-checkbox class="ion-invalid ion-touched" error-text="Error text">Label</ion-checkbox>
`,
config
);
const errorText = page.locator('ion-checkbox');
await expect(errorText).toHaveScreenshot(screenshot(`checkbox-error-text-custom-css`));
});
});
});

View File

@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Checkbox - Bottom Content</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-row-gap: 20px;
grid-column-gap: 20px;
}
h2 {
font-size: 12px;
font-weight: normal;
color: #6f7378;
margin-top: 10px;
}
ion-checkbox {
width: 100%;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Checkbox - Bottom Content</ion-title>
</ion-toolbar>
</ion-header>
<ion-content id="content" class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>No Hint</h2>
<ion-checkbox>Label</ion-checkbox>
</div>
<div class="grid-item">
<h2>No Hint: Stacked</h2>
<ion-checkbox label-placement="stacked">Label</ion-checkbox>
</div>
<div class="grid-item">
<h2>Helper Text: Label Start</h2>
<ion-checkbox helper-text="Helper text" error-text="Error text">Label</ion-checkbox>
</div>
<div class="grid-item">
<h2>Helper Text: Label End</h2>
<ion-checkbox label-placement="end" helper-text="Helper text" error-text="Error text">Label</ion-checkbox>
</div>
<div class="grid-item">
<h2>Helper Text: Label Stacked</h2>
<ion-checkbox label-placement="stacked" helper-text="Helper text" error-text="Error text"
>Label</ion-checkbox
>
</div>
<div class="grid-item">
<h2>Helper Text: Label Fixed</h2>
<ion-checkbox label-placement="fixed" helper-text="Helper text" error-text="Error text">Label</ion-checkbox>
</div>
<div class="grid-item">
<h2>Error Text: Label Start</h2>
<ion-checkbox helper-text="Helper text" error-text="Error text" class="ion-invalid ion-touched"
>Label</ion-checkbox
>
</div>
<div class="grid-item">
<h2>Error Text: Label End</h2>
<ion-checkbox
label-placement="end"
helper-text="Helper text"
error-text="Error text"
class="ion-invalid ion-touched"
>Label</ion-checkbox
>
</div>
<div class="grid-item">
<h2>Error Text: Label Stacked</h2>
<ion-checkbox
label-placement="stacked"
helper-text="Helper text"
error-text="Error text"
class="ion-invalid ion-touched"
>Label</ion-checkbox
>
</div>
<div class="grid-item">
<h2>Error Text: Label Fixed</h2>
<ion-checkbox
label-placement="fixed"
helper-text="Helper text"
error-text="Error text"
class="ion-invalid ion-touched"
>Label</ion-checkbox
>
</div>
</div>
<button onclick="toggleValid()" class="expand">Toggle error</button>
<script>
const checkboxes = document.querySelectorAll('ion-checkbox[helper-text]');
function toggleValid() {
checkboxes.forEach((checkbox) => {
checkbox.classList.toggle('ion-invalid');
checkbox.classList.toggle('ion-touched');
});
}
</script>
</ion-content>
</ion-app>
</body>
</html>

View File

@ -53,7 +53,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('checkbox: long label in item'), () => {
test('should render margins correctly when using long label in item', async ({ page }) => {
test('should not have visual regressions when using long label in item', async ({ page }) => {
await page.setContent(
`
<ion-list>
@ -69,7 +69,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
const list = page.locator('ion-list');
await expect(list).toHaveScreenshot(screenshot(`checkbox-long-label-in-item`));
});
test('should render margins correctly when using long label in item with start alignment', async ({
test('should not have visual regressions when using long label in item with start alignment', async ({
page,
}, testInfo) => {
testInfo.annotations.push({
@ -93,8 +93,25 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
test.describe(title('checkbox: end label in item'), () => {
test('should not have visual regressions when using end label in item', async ({ page }) => {
await page.setContent(
`
<ion-list>
<ion-item>
<ion-checkbox label-placement="end">Enable Notifications</ion-checkbox>
</ion-item>
</ion-list>
`,
config
);
const list = page.locator('ion-list');
await expect(list).toHaveScreenshot(screenshot(`checkbox-end-label-in-item`));
});
});
test.describe(title('checkbox: stacked label in item'), () => {
test('should render margins correctly when using stacked label in item', async ({ page }) => {
test('should not have visual regressions when using stacked label in item', async ({ page }) => {
await page.setContent(
`
<ion-list>

View File

@ -49,6 +49,15 @@
<ion-content id="content" class="ion-padding">
<h1>Placement Start</h1>
<div class="grid">
<div class="grid-item">
<h2>Default Justify</h2>
<ion-list>
<ion-item>
<ion-checkbox label-placement="start">Enable Notifications</ion-checkbox>
</ion-item>
</ion-list>
</div>
<div class="grid-item">
<h2>Justify Start</h2>
<ion-list>
@ -79,6 +88,15 @@
<h1>Placement End</h1>
<div class="grid">
<div class="grid-item">
<h2>Default Justify</h2>
<ion-list>
<ion-item>
<ion-checkbox label-placement="end">Enable Notifications</ion-checkbox>
</ion-item>
</ion-list>
</div>
<div class="grid-item">
<h2>Justify Start</h2>
<ion-list>
@ -109,6 +127,15 @@
<h1>Placement Fixed</h1>
<div class="grid">
<div class="grid-item">
<h2>Default Justify</h2>
<ion-list>
<ion-item>
<ion-checkbox label-placement="fixed">Enable Notifications</ion-checkbox>
</ion-item>
</ion-list>
</div>
<div class="grid-item">
<h2>Justify Start</h2>
<ion-list>
@ -139,6 +166,15 @@
<h1>Placement Stacked</h1>
<div class="grid">
<div class="grid-item">
<h2>Default Align</h2>
<ion-list>
<ion-item>
<ion-checkbox label-placement="stacked">Enable Notifications</ion-checkbox>
</ion-item>
</ion-list>
</div>
<div class="grid-item">
<h2>Align Start</h2>
<ion-list>
@ -190,6 +226,24 @@
</ion-checkbox>
</ion-item>
</div>
<div class="grid-item">
<ion-item>
<ion-checkbox label-placement="end">
<ion-label class="ion-text-wrap">
Enable Notifications Enable Notifications Enable Notifications
</ion-label>
</ion-checkbox>
</ion-item>
</div>
<div class="grid-item">
<ion-item>
<ion-checkbox label-placement="end" alignment="start">
<ion-label class="ion-text-wrap">
Enable Notifications Enable Notifications Enable Notifications
</ion-label>
</ion-checkbox>
</ion-item>
</div>
</div>
</ion-content>
</ion-app>