fix(input): update helper text and counter color (#30149)

Issue number: N/A

---------

## What is the current behavior?
Helper text is lighter than it should be.

## What is the new behavior?
- Updates helper and counter text to match MD design
- Updates e2e test to include more coverage

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

## Other information


[Preview](https://ionic-framework-git-rou-11558-ionic1.vercel.app/src/components/input/test/bottom-content)

> Note that the fill toggle will only work in `md` mode

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
This commit is contained in:
Brandy Smith
2025-03-04 15:29:35 -05:00
committed by GitHub
parent f4941f2639
commit fdd52832c6
259 changed files with 298 additions and 198 deletions

View File

@ -32,6 +32,10 @@
--border-color: var(--highlight-color);
}
/**
* The bottom content should never have
* a border with the solid style.
*/
:host(.input-fill-solid) .input-bottom {
border-top: none;
}

View File

@ -306,6 +306,8 @@
border-top: var(--border-width) var(--border-style) var(--border-color);
font-size: dynamic-font(12px);
white-space: normal;
}
/**
@ -340,7 +342,7 @@
.input-bottom .helper-text {
display: block;
color: #{$text-color-step-450};
color: $text-color-step-300;
}
:host(.ion-touched.ion-invalid) .input-bottom .error-text {
@ -362,7 +364,7 @@
*/
@include margin-horizontal(auto, null);
color: #{$text-color-step-450};
color: $text-color-step-300;
white-space: nowrap;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -15,10 +15,11 @@
<style>
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-row-gap: 20px;
grid-column-gap: 20px;
}
h2 {
font-size: 12px;
font-weight: normal;
@ -27,12 +28,6 @@
margin-top: 10px;
}
@media screen and (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
padding: 0;
}
}
ion-input.custom-error-color {
--highlight-color-invalid: purple;
@ -40,7 +35,7 @@
</style>
</head>
<body>
<body onLoad="onLoad()">
<ion-app>
<ion-header>
<ion-toolbar>
@ -52,67 +47,120 @@
<div class="grid">
<div class="grid-item">
<h2>No Hint</h2>
<ion-input label="Email"></ion-input>
<ion-input label="Label"></ion-input>
</div>
<div class="grid-item">
<h2>Helper Hint</h2>
<ion-input label="Email" helper-text="Enter your email"></ion-input>
<h2>No Hint: Stacked</h2>
<ion-input label="Label" label-placement="stacked"></ion-input>
</div>
<div class="grid-item">
<h2>Error Hint</h2>
<h2>Helper Text</h2>
<ion-input label="Label" helper-text="Helper text"></ion-input>
</div>
<div class="grid-item">
<h2>Helper Text: Stacked</h2>
<ion-input label="Label" label-placement="stacked" helper-text="Helper text"></ion-input>
</div>
<div class="grid-item">
<h2>Error Text</h2>
<ion-input class="ion-touched ion-invalid" label="Label" error-text="Error text"></ion-input>
</div>
<div class="grid-item">
<h2>Error Text: Stacked</h2>
<ion-input
class="ion-touched ion-invalid"
label="Email"
error-text="Please enter a valid email"
label="Label"
label-placement="stacked"
error-text="Error text"
></ion-input>
</div>
<div class="grid-item">
<h2>Custom Error Color</h2>
<h2>Error Text: Custom Color</h2>
<ion-input
class="ion-touched ion-invalid custom-error-color"
label="Email"
error-text="Please enter a valid email"
label="Label"
error-text="Error text"
></ion-input>
</div>
<div class="grid-item">
<h2>Helper Text: Wrapping</h2>
<ion-input
label="Label"
helper-text="Helper text helper text helper text helper text helper text helper text helper text helper text helper text"
>
</ion-input>
</div>
<div class="grid-item">
<h2>Counter</h2>
<ion-input label="Email" counter="true" maxlength="100"></ion-input>
<ion-input label="Label" counter="true" maxlength="100"></ion-input>
</div>
<div class="grid-item">
<h2>Custom Counter</h2>
<ion-input id="custom-counter" label="Email" counter="true" maxlength="100"></ion-input>
<h2>Counter: Custom</h2>
<ion-input id="custom-counter" label="Label" counter="true" maxlength="100"></ion-input>
</div>
<div class="grid-item">
<h2>Counter with Helper</h2>
<ion-input label="Email" counter="true" maxlength="100" helper-text="Enter an email"></ion-input>
<h2>Counter: with Helper</h2>
<ion-input label="Label" counter="true" maxlength="100" helper-text="Helper text"></ion-input>
</div>
<div class="grid-item">
<h2>Counter with Error</h2>
<h2>Counter: with Error</h2>
<ion-input
class="ion-touched ion-invalid"
label="Email"
label="Label"
counter="true"
maxlength="100"
error-text="Please enter a valid email"
error-text="Error text"
></ion-input>
</div>
</div>
<button class="expand" onclick="toggleFill()">Toggle Fill</button>
</ion-content>
</ion-app>
<script>
// Hide the toggle fill button on ios mode since it's not supported
function onLoad() {
const toggleFillButton = document.querySelector('button');
if (Ionic.mode === 'ios' && toggleFillButton) {
toggleFillButton.style.display = 'none';
}
}
const customCounterInput = document.querySelector('ion-input#custom-counter');
customCounterInput.counterFormatter = (inputLength, maxLength) => {
const length = maxLength - inputLength;
return `${maxLength - inputLength} characters left`;
};
const inputs = document.querySelectorAll('ion-input');
function toggleFill() {
inputs.forEach((input) => {
switch (input.fill) {
case 'outline':
input.fill = 'solid';
break;
case 'solid':
input.fill = undefined;
break;
default:
input.fill = 'outline';
}
});
}
</script>
</ion-content>
</ion-app>
</body>
</html>

View File

@ -1,76 +1,32 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('input: bottom content'), () => {
test('entire input component should render correctly with no fill', async ({ page }) => {
await page.setContent(
`
<ion-input value="hi@ionic.io" label="Email" helper-text="Enter an email" maxlength="20" counter="true"></ion-input>
`,
config
);
const input = page.locator('ion-input');
await expect(input).toHaveScreenshot(screenshot(`input-full-bottom-no-fill`));
});
test('entire input component should render correctly with solid fill', async ({ page }) => {
await page.setContent(
`
<ion-input fill="solid" value="hi@ionic.io" label="Email" helper-text="Enter an email" maxlength="20" counter="true"></ion-input>
`,
config
);
const input = page.locator('ion-input');
await expect(input).toHaveScreenshot(screenshot(`input-full-bottom-solid`));
});
test('entire input component should render correctly with outline fill', async ({ page }) => {
await page.setContent(
`
<ion-input fill="outline" value="hi@ionic.io" label="Email" helper-text="Enter an email" maxlength="20" counter="true"></ion-input>
`,
config
);
const input = page.locator('ion-input');
await expect(input).toHaveScreenshot(screenshot(`input-full-bottom-outline`));
});
});
});
/**
* Rendering is the same across modes
* Functionality is the same across modes & directions
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('input: bottom content functionality'), () => {
test('should not render bottom content if no hint or counter is enabled', async ({ page }) => {
await page.setContent(`<ion-input label="my input"></ion-input>`, config);
test('should not render bottom content if no hint is enabled', async ({ page }) => {
await page.setContent(`<ion-input label="Label"></ion-input>`, config);
const bottomEl = page.locator('ion-input .input-bottom');
await expect(bottomEl).toHaveCount(0);
});
});
});
/**
* Rendering is the same across modes
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('input: hint text'), () => {
test.describe('input: hint text functionality', () => {
test('helper text should be visible initially', async ({ page }) => {
await page.setContent(
`<ion-input helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
`<ion-input label="Label" helper-text="Helper text" error-text="Error text"></ion-input>`,
config
);
const helperText = page.locator('ion-input .helper-text');
const errorText = page.locator('ion-input .error-text');
await expect(helperText).toBeVisible();
await expect(helperText).toHaveText('my helper');
await expect(helperText).toHaveText('Helper text');
await expect(errorText).toBeHidden();
});
test('input should have an aria-describedby attribute when helper text is present', async ({ page }) => {
await page.setContent(
`<ion-input helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
`<ion-input label="Label" helper-text="Helper text" error-text="Error text"></ion-input>`,
config
);
@ -83,7 +39,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
});
test('error text should be visible when input is invalid', async ({ page }) => {
await page.setContent(
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
`<ion-input label="Label" class="ion-invalid ion-touched" helper-text="Helper text" error-text="Error text"></ion-input>`,
config
);
@ -91,27 +47,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
const errorText = page.locator('ion-input .error-text');
await expect(helperText).toBeHidden();
await expect(errorText).toBeVisible();
await expect(errorText).toHaveText('my error');
await expect(errorText).toHaveText('Error text');
});
test('error text should change when variable is customized', async ({ page }) => {
await page.setContent(
`
<style>
ion-input.custom-input {
--highlight-color-invalid: purple;
}
</style>
<ion-input class="ion-invalid ion-touched custom-input" label="my label" error-text="my error"></ion-input>
`,
config
);
const errorText = page.locator('ion-input .error-text');
await expect(errorText).toHaveScreenshot(screenshot(`input-error-custom-color`));
});
test('input should have an aria-describedby attribute when error text is present', async ({ page }) => {
await page.setContent(
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
`<ion-input label="Label" class="ion-invalid ion-touched" helper-text="Helper text" error-text="Error text"></ion-input>`,
config
);
@ -124,7 +65,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
});
test('input should have aria-invalid attribute when input is invalid', async ({ page }) => {
await page.setContent(
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
`<ion-input label="Label" class="ion-invalid ion-touched" helper-text="Helper text" error-text="Error text"></ion-input>`,
config
);
@ -134,7 +75,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
});
test('input should not have aria-invalid attribute when input is valid', async ({ page }) => {
await page.setContent(
`<ion-input helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
`<ion-input label="Label" helper-text="Helper text" error-text="Error text"></ion-input>`,
config
);
@ -142,48 +83,136 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
await expect(input).not.toHaveAttribute('aria-invalid');
});
test('input should not have aria-describedby attribute when no hint or error text is present', async ({
page,
}) => {
await page.setContent(`<ion-input label="my input"></ion-input>`, config);
test('input should not have aria-describedby attribute when no hint or error text is present', async ({ page }) => {
await page.setContent(`<ion-input label="Label"></ion-input>`, config);
const input = page.locator('ion-input input');
await expect(input).not.toHaveAttribute('aria-describedby');
});
});
test.describe('input: hint text rendering', () => {
test.describe('regular inputs', () => {
test('should not have visual regressions when rendering helper text', async ({ page }) => {
await page.setContent(`<ion-input helper-text="my helper" label="my input"></ion-input>`, config);
});
const bottomEl = page.locator('ion-input .input-bottom');
await expect(bottomEl).toHaveScreenshot(screenshot(`input-bottom-content-helper`));
/**
* Rendering is different across modes
*/
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('input: helper text rendering'), () => {
test('should not have visual regressions when rendering helper text', async ({ page }) => {
await page.setContent(`<ion-input label="Label" helper-text="Helper text"></ion-input>`, config);
const bottomEl = page.locator('ion-input');
await expect(bottomEl).toHaveScreenshot(screenshot(`input-helper-text`));
});
test('should not have visual regressions when rendering error text', async ({ page }) => {
test('should not have visual regressions when rendering helper text with wrapping text', async ({ page }) => {
await page.setContent(
`<ion-input class="ion-invalid ion-touched" error-text="my helper" label="my input"></ion-input>`,
`<ion-input label="Label" helper-text="Helper text helper text helper text helper text helper text helper text helper text helper text helper text"></ion-input>`,
config
);
const bottomEl = page.locator('ion-input .input-bottom');
await expect(bottomEl).toHaveScreenshot(screenshot(`input-bottom-content-error`));
const bottomEl = page.locator('ion-input');
await expect(bottomEl).toHaveScreenshot(screenshot(`input-helper-text-wrapping`));
});
test('should not have visual regressions when rendering helper text with a stacked label', async ({ page }) => {
await page.setContent(
`<ion-input label="Label" label-placement="stacked" helper-text="Helper text"></ion-input>`,
config
);
const bottomEl = page.locator('ion-input');
await expect(bottomEl).toHaveScreenshot(screenshot(`input-helper-text-stacked-label`));
});
});
test.describe(title('input: error text rendering'), () => {
test('should not have visual regressions when rendering error text', async ({ page }) => {
await page.setContent(
`<ion-input label="Label" class="ion-invalid ion-touched" error-text="Error text"></ion-input>`,
config
);
const bottomEl = page.locator('ion-input');
await expect(bottomEl).toHaveScreenshot(screenshot(`input-error-text`));
});
test('should not have visual regressions when rendering error text with a stacked label', async ({ page }) => {
await page.setContent(
`<ion-input label="Label" class="ion-invalid ion-touched" error-text="Error text" label-placement="stacked"></ion-input>`,
config
);
const bottomEl = page.locator('ion-input');
await expect(bottomEl).toHaveScreenshot(screenshot(`input-error-text-stacked-label`));
});
});
});
/**
* Rendering is the same across modes
* Customizing supporting text is the same across modes and directions
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('input: supporting text customization'), () => {
test('should not have visual regressions when rendering helper text with custom css', async ({ page }) => {
await page.setContent(
`
<style>
ion-input.custom-input.md .input-bottom .helper-text {
font-size: 20px;
color: green;
}
</style>
<ion-input class="custom-input" label="Label" helper-text="Helper text"></ion-input>
`,
config
);
const helperText = page.locator('ion-input');
await expect(helperText).toHaveScreenshot(screenshot(`input-helper-text-custom-css`));
});
test('should not have visual regressions when rendering error text with custom css', async ({ page }) => {
await page.setContent(
`
<style>
ion-input.custom-input.md .input-bottom .error-text {
font-size: 20px;
color: purple;
}
</style>
<ion-input class="ion-invalid ion-touched custom-input" label="Label" error-text="Error text"></ion-input>
`,
config
);
const errorText = page.locator('ion-input');
await expect(errorText).toHaveScreenshot(screenshot(`input-error-text-custom-css`));
});
test('should not have visual regressions when rendering error text with a custom css variable', async ({
page,
}) => {
await page.setContent(
`
<style>
ion-input.custom-input {
--highlight-color-invalid: purple;
}
</style>
<ion-input class="ion-invalid ion-touched custom-input" label="Label" error-text="Error text"></ion-input>
`,
config
);
const errorText = page.locator('ion-input');
await expect(errorText).toHaveScreenshot(screenshot(`input-error-text-custom-css-var`));
});
});
});
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('input: counter'), () => {
test.describe('input: counter functionality', () => {
test('should not activate if maxlength is not specified even if bottom content is visible', async ({ page }) => {
await page.setContent(
`
<ion-input label="my label" counter="true" helper-text="helper text"></ion-input>
<ion-input label="Label" counter="true" helper-text="helper text"></ion-input>
`,
config
);
@ -193,7 +222,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
test('default formatter should be used', async ({ page }) => {
await page.setContent(
`
<ion-input label="my label" counter="true" maxlength="20"></ion-input>
<ion-input label="Label" counter="true" maxlength="20"></ion-input>
`,
config
);
@ -203,8 +232,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
test('custom formatter should be used when provided', async ({ page }) => {
await page.setContent(
`
<ion-input label="my label" counter="true" maxlength="20"></ion-input>
<ion-input label="Label" counter="true" maxlength="20"></ion-input>
<script>
const input = document.querySelector('ion-input');
input.counterFormatter = (inputLength, maxLength) => {
@ -229,13 +257,31 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
});
});
test.describe('input: counter rendering', () => {
test.describe('regular inputs', () => {
test('should not have visual regressions when rendering counter', async ({ page }) => {
await page.setContent(`<ion-input counter="true" maxlength="20" label="my input"></ion-input>`, config);
await page.setContent(`<ion-input counter="true" maxlength="20" label="Label"></ion-input>`, config);
const bottomEl = page.locator('ion-input .input-bottom');
await expect(bottomEl).toHaveScreenshot(screenshot(`input-bottom-content-counter`));
const bottomEl = page.locator('ion-input');
await expect(bottomEl).toHaveScreenshot(screenshot(`input-counter`));
});
test('should not have visual regressions when rendering counter with helper text', async ({ page }) => {
await page.setContent(
`<ion-input label="Label" counter="true" maxlength="20" helper-text="Helper"></ion-input>`,
config
);
const bottomEl = page.locator('ion-input');
await expect(bottomEl).toHaveScreenshot(screenshot(`input-counter-helper-text`));
});
test('should not have visual regressions when rendering counter with error text', async ({ page }) => {
await page.setContent(
`<ion-input class="ion-invalid ion-touched" label="Label" counter="true" maxlength="20" error-text="Error text"></ion-input>`,
config
);
const bottomEl = page.locator('ion-input');
await expect(bottomEl).toHaveScreenshot(screenshot(`input-counter-error-text`));
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Some files were not shown because too many files have changed in this diff Show More