diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 4b5a222fc83..9f71cd40a9c 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1205,10 +1205,12 @@ export namespace Components { "color"?: Color; /** * If `true`, a character counter will display the ratio of characters used and the total character limit. Only applies when the `maxlength` property is set on the inner `ion-input` or `ion-textarea`. + * @deprecated Use the `counter` property on `ion-input` or `ion-textarea` instead. */ "counter": boolean; /** * A callback used to format the counter text. By default the counter text is set to "itemLength / maxLength". + * @deprecated Use the `counterFormatter` property on `ion-input` or `ion-textarea` instead. */ "counterFormatter"?: CounterFormatter; /** @@ -5055,10 +5057,12 @@ declare namespace LocalJSX { "color"?: Color; /** * If `true`, a character counter will display the ratio of characters used and the total character limit. Only applies when the `maxlength` property is set on the inner `ion-input` or `ion-textarea`. + * @deprecated Use the `counter` property on `ion-input` or `ion-textarea` instead. */ "counter"?: boolean; /** * A callback used to format the counter text. By default the counter text is set to "itemLength / maxLength". + * @deprecated Use the `counterFormatter` property on `ion-input` or `ion-textarea` instead. */ "counterFormatter"?: CounterFormatter; /** diff --git a/core/src/components/input/input.md.scss b/core/src/components/input/input.md.scss index c65da943cc3..6e58b9deae8 100644 --- a/core/src/components/input/input.md.scss +++ b/core/src/components/input/input.md.scss @@ -31,3 +31,9 @@ background-size: $input-md-input-clear-icon-size; } + +// Input Max Length Counter +// ---------------------------------------------------------------- +.input-bottom .counter { + letter-spacing: .0333333333em; +} diff --git a/core/src/components/input/input.scss b/core/src/components/input/input.scss index 80a62771693..b9db51ecc23 100644 --- a/core/src/components/input/input.scss +++ b/core/src/components/input/input.scss @@ -258,3 +258,21 @@ :host(.ion-invalid) .input-bottom .helper-text { display: none; } + +// Input Max Length Counter +// ---------------------------------------------------------------- + +.input-bottom .counter { + /** + * Counter should always be at + * the end of the container even + * when no helper/error texts are used. + */ + @include margin-horizontal(auto, null); + + color: #{$background-color-step-550}; + + white-space: nowrap; + + padding-inline-start: 16px; +} diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 8f1d62935dd..e1e0198987a 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -17,6 +17,8 @@ import type { Attributes } from '../../utils/helpers'; import { inheritAriaAttributes, debounceEvent, findItemLabel, inheritAttributes } from '../../utils/helpers'; import { createColorClasses } from '../../utils/theme'; +import { getCounterText } from './input.utils'; + /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. */ @@ -521,20 +523,35 @@ export class Input implements ComponentInterface { return [
{helperText}
,
{errorText}
]; } + private renderCounter() { + const { counter, maxlength, counterFormatter, value } = this; + if (counter !== true || maxlength === undefined) { + return; + } + + return
{getCounterText(value, maxlength, counterFormatter)}
; + } + /** * Responsible for rendering helper text, * error text, and counter. This element should only * be rendered if hint text is set or counter is enabled. */ private renderBottomContent() { - const { helperText, errorText } = this; + const { counter, helperText, errorText, maxlength } = this; const hasHintText = helperText !== undefined || errorText !== undefined; - if (!hasHintText) { + const hasCounter = counter === true && maxlength !== undefined; + if (!hasHintText && !hasCounter) { return; } - return
{this.renderHintText()}
; + return ( +
+ {this.renderHintText()} + {this.renderCounter()} +
+ ); } private renderInput() { diff --git a/core/src/components/input/input.utils.ts b/core/src/components/input/input.utils.ts new file mode 100644 index 00000000000..d6812be12d8 --- /dev/null +++ b/core/src/components/input/input.utils.ts @@ -0,0 +1,34 @@ +import { printIonError } from '@utils/logging'; + +export const getCounterText = ( + value: string | number | null | undefined, + maxLength: number, + counterFormatter?: (inputLength: number, maxLength: number) => string +) => { + const valueLength = value == null ? 0 : value.toString().length; + const defaultCounterText = defaultCounterFormatter(valueLength, maxLength); + + /** + * If developers did not pass a custom formatter, + * use the default one. + */ + if (counterFormatter === undefined) { + return defaultCounterText; + } + + /** + * Otherwise, try to use the custom formatter + * and fallback to the default formatter if + * there was an error. + */ + try { + return counterFormatter(valueLength, maxLength); + } catch (e) { + printIonError('Exception in provided `counterFormatter`.', e); + return defaultCounterText; + } +}; + +const defaultCounterFormatter = (length: number, maxlength: number) => { + return `${length} / ${maxlength}`; +}; diff --git a/core/src/components/input/test/bottom-content/index.html b/core/src/components/input/test/bottom-content/index.html index a2d4298d077..377c232b7c4 100644 --- a/core/src/components/input/test/bottom-content/index.html +++ b/core/src/components/input/test/bottom-content/index.html @@ -73,7 +73,40 @@

Custom Error Color

error-text="Please enter a valid email" > +
+

Counter

+ +
+ +
+

Custom Counter

+ +
+ +
+

Counter with Helper

+ +
+ +
+

Counter with Error

+ +
+ + diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts b/core/src/components/input/test/bottom-content/input.e2e.ts index 301c43778c0..4d449afb3ec 100644 --- a/core/src/components/input/test/bottom-content/input.e2e.ts +++ b/core/src/components/input/test/bottom-content/input.e2e.ts @@ -77,3 +77,61 @@ test.describe('input: hint text', () => { }); }); }); +test.describe('input: counter', () => { + test.describe('input: counter functionality', () => { + test.beforeEach(({ skip }) => { + skip.rtl(); + skip.mode('ios', 'Rendering is the same across modes'); + }); + test('should not activate if maxlength is not specified even if bottom content is visible', async ({ page }) => { + await page.setContent(` + + `); + const itemCounter = page.locator('ion-input .counter'); + await expect(itemCounter).toBeHidden(); + }); + test('default formatter should be used', async ({ page }) => { + await page.setContent(` + + `); + const itemCounter = page.locator('ion-input .counter'); + expect(await itemCounter.textContent()).toBe('0 / 20'); + }); + test('custom formatter should be used when provided', async ({ page }) => { + await page.setContent(` + + + + `); + + const input = page.locator('ion-input input'); + const itemCounter = page.locator('ion-input .counter'); + expect(await itemCounter.textContent()).toBe('20 characters left'); + + await input.click(); + await input.type('abcde'); + + await page.waitForChanges(); + + expect(await itemCounter.textContent()).toBe('15 characters left'); + }); + }); + test.describe('input: counter rendering', () => { + test.describe('regular inputs', () => { + test('should not have visual regressions when rendering counter', async ({ page }) => { + await page.setContent(``); + + const bottomEl = page.locator('ion-input .input-bottom'); + expect(await bottomEl.screenshot()).toMatchSnapshot( + `input-bottom-content-counter-${page.getSnapshotSettings()}.png` + ); + }); + }); + }); +}); diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..ce9ce14f488 Binary files /dev/null and b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..1909fa0f2c4 Binary files /dev/null and b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-ltr-Mobile-Safari-linux.png b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..2a8821a23fe Binary files /dev/null and b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..31136d00fab Binary files /dev/null and b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..242125219c9 Binary files /dev/null and b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-rtl-Mobile-Safari-linux.png b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..2eda5911676 Binary files /dev/null and b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-ltr-Mobile-Chrome-linux.png b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..82b6b76e855 Binary files /dev/null and b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-ltr-Mobile-Firefox-linux.png b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..b23e02bbc1f Binary files /dev/null and b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-ltr-Mobile-Safari-linux.png b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..7d3323542bb Binary files /dev/null and b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-rtl-Mobile-Chrome-linux.png b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..a2ac2d610bd Binary files /dev/null and b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-rtl-Mobile-Firefox-linux.png b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..f799e152c42 Binary files /dev/null and b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-rtl-Mobile-Safari-linux.png b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..a298f83268a Binary files /dev/null and b/core/src/components/input/test/bottom-content/input.e2e.ts-snapshots/input-bottom-content-counter-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx index 317c3647c50..afa5761d6fd 100644 --- a/core/src/components/item/item.tsx +++ b/core/src/components/item/item.tsx @@ -111,6 +111,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac /** * If `true`, a character counter will display the ratio of characters used and the total character limit. Only applies when the `maxlength` property is set on the inner `ion-input` or `ion-textarea`. + * @deprecated Use the `counter` property on `ion-input` or `ion-textarea` instead. */ @Prop() counter = false; @@ -141,6 +142,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac /** * A callback used to format the counter text. * By default the counter text is set to "itemLength / maxLength". + * @deprecated Use the `counterFormatter` property on `ion-input` or `ion-textarea` instead. */ @Prop() counterFormatter?: CounterFormatter; @@ -207,7 +209,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac } componentDidLoad() { - const { el } = this; + const { el, counter, counterFormatter } = this; const hasHelperSlot = el.querySelector('[slot="helper"]') !== null; if (hasHelperSlot) { printIonWarning( @@ -224,6 +226,20 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac ); } + if (counter === true) { + printIonWarning( + 'The "counter" property has been deprecated in favor of using the "counter" property on ion-input or ion-textarea.', + el + ); + } + + if (counterFormatter !== undefined) { + printIonWarning( + 'The "counterFormatter" property has been deprecated in favor of using the "counterFormatter" property on ion-input or ion-textarea.', + el + ); + } + raf(() => { this.inheritedAriaAttributes = inheritAttributes(el, ['aria-label']); this.setMultipleInputs();