diff --git a/angular/src/directives/proxies.ts b/angular/src/directives/proxies.ts
index ac79be5095..a323fedfb7 100644
--- a/angular/src/directives/proxies.ts
+++ b/angular/src/directives/proxies.ts
@@ -834,13 +834,13 @@ export declare interface IonItem extends Components.IonItem {}
@ProxyCmp({
defineCustomElementFn: undefined,
- inputs: ['button', 'color', 'counter', 'detail', 'detailIcon', 'disabled', 'download', 'fill', 'href', 'lines', 'mode', 'rel', 'routerAnimation', 'routerDirection', 'shape', 'target', 'type']
+ inputs: ['button', 'color', 'counter', 'counterFormatter', 'detail', 'detailIcon', 'disabled', 'download', 'fill', 'href', 'lines', 'mode', 'rel', 'routerAnimation', 'routerDirection', 'shape', 'target', 'type']
})
@Component({
selector: 'ion-item',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '',
- inputs: ['button', 'color', 'counter', 'detail', 'detailIcon', 'disabled', 'download', 'fill', 'href', 'lines', 'mode', 'rel', 'routerAnimation', 'routerDirection', 'shape', 'target', 'type']
+ inputs: ['button', 'color', 'counter', 'counterFormatter', 'detail', 'detailIcon', 'disabled', 'download', 'fill', 'href', 'lines', 'mode', 'rel', 'routerAnimation', 'routerDirection', 'shape', 'target', 'type']
})
export class IonItem {
protected el: HTMLElement;
diff --git a/core/api.txt b/core/api.txt
index b74dbaa806..f5a0b89220 100644
--- a/core/api.txt
+++ b/core/api.txt
@@ -557,6 +557,7 @@ ion-item,shadow
ion-item,prop,button,boolean,false,false,false
ion-item,prop,color,string | undefined,undefined,false,true
ion-item,prop,counter,boolean,false,false,false
+ion-item,prop,counterFormatter,((inputLength: number, maxLength: number) => string) | undefined,undefined,false,false
ion-item,prop,detail,boolean | undefined,undefined,false,false
ion-item,prop,detailIcon,string,chevronForward,false,false
ion-item,prop,disabled,boolean,false,false,false
diff --git a/core/src/components.d.ts b/core/src/components.d.ts
index 94e417edaa..3f8d3b536d 100644
--- a/core/src/components.d.ts
+++ b/core/src/components.d.ts
@@ -8,6 +8,7 @@ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal";
import { AccordionGroupChangeEventDetail, ActionSheetAttributes, ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, BreadcrumbCollapsedClickEventDetail, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DomRenderFn, FooterHeightFn, FrameworkDelegate, HeaderFn, HeaderHeightFn, InputChangeEventDetail, ItemHeightFn, ItemRenderFn, ItemReorderEventDetail, LoadingAttributes, MenuChangeEventDetail, ModalAttributes, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerAttributes, PickerButton, PickerColumn, PopoverAttributes, PopoverSize, PositionAlign, PositionReference, PositionSide, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeValue, RefresherEventDetail, RouteID, RouterDirection, RouterEventDetail, RouterOutletOptions, RouteWrite, ScrollBaseDetail, ScrollDetail, SearchbarChangeEventDetail, SegmentButtonLayout, SegmentChangeEventDetail, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, Side, SpinnerTypes, StyleEventDetail, SwipeGestureHandler, TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout, TextareaChangeEventDetail, TextFieldTypes, ToastButton, ToggleChangeEventDetail, TransitionDoneFn, TransitionInstruction, TriggerAction, ViewController } from "./interface";
import { IonicSafeString } from "./utils/sanitization";
import { AlertAttributes } from "./components/alert/alert-interface";
+import { CounterFormatter } from "./components/item/item-interface";
import { PickerColumnItem } from "./components/picker-column-internal/picker-column-internal-interfaces";
import { PickerInternalChangeEventDetail } from "./components/picker-internal/picker-internal-interfaces";
import { PinFormatter } from "./components/range/range-interface";
@@ -1138,6 +1139,10 @@ export namespace Components {
* 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`.
*/
"counter": boolean;
+ /**
+ * A callback used to format the counter text. By default the counter text is set to "itemLength / maxLength".
+ */
+ "counterFormatter"?: CounterFormatter;
/**
* If `true`, a detail arrow will appear on the item. Defaults to `false` unless the `mode` is `ios` and an `href` or `button` property is present.
*/
@@ -4872,6 +4877,10 @@ declare namespace LocalJSX {
* 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`.
*/
"counter"?: boolean;
+ /**
+ * A callback used to format the counter text. By default the counter text is set to "itemLength / maxLength".
+ */
+ "counterFormatter"?: CounterFormatter;
/**
* If `true`, a detail arrow will appear on the item. Defaults to `false` unless the `mode` is `ios` and an `href` or `button` property is present.
*/
diff --git a/core/src/components/item/item-interface.ts b/core/src/components/item/item-interface.ts
new file mode 100644
index 0000000000..22cef4c082
--- /dev/null
+++ b/core/src/components/item/item-interface.ts
@@ -0,0 +1,2 @@
+
+export type CounterFormatter = (inputLength: number, maxLength: number) => string;
diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx
index 7254f9be2c..cc9766ff57 100644
--- a/core/src/components/item/item.tsx
+++ b/core/src/components/item/item.tsx
@@ -1,4 +1,5 @@
-import { Component, ComponentInterface, Element, Host, Listen, Prop, State, forceUpdate, h } from '@stencil/core';
+import { Component, ComponentInterface, Element, Host, Listen, Prop, State, Watch, forceUpdate, h } from '@stencil/core';
+import { printIonError } from '@utils/logging';
import { chevronForward } from 'ionicons/icons';
import { getIonMode } from '../../global/ionic-global';
@@ -8,6 +9,8 @@ import { raf } from '../../utils/helpers';
import { createColorClasses, hostContext, openURL } from '../../utils/theme';
import { InputChangeEventDetail } from '../input/input-interface';
+import { CounterFormatter } from './item-interface';
+
/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
*
@@ -134,8 +137,19 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
*/
@Prop() type: 'submit' | 'reset' | 'button' = 'button';
+ /**
+ * A callback used to format the counter text.
+ * By default the counter text is set to "itemLength / maxLength".
+ */
+ @Prop() counterFormatter?: CounterFormatter;
+
@State() counterString: string | null | undefined;
+ @Watch('counterFormatter')
+ counterFormatterChanged() {
+ this.updateCounterOutput(this.getFirstInput());
+ }
+
@Listen('ionChange')
handleIonChange(ev: CustomEvent) {
if (this.counter && ev.target === this.getFirstInput()) {
@@ -296,12 +310,27 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
}
private updateCounterOutput(inputEl: HTMLIonInputElement | HTMLIonTextareaElement) {
- if (this.counter && !this.multipleInputs && inputEl?.maxlength !== undefined) {
- const length = inputEl?.value?.toString().length ?? '0';
- this.counterString = `${length} / ${inputEl.maxlength}`;
+ const { counter, counterFormatter, defaultCounterFormatter } = this;
+ if (counter && !this.multipleInputs && inputEl?.maxlength !== undefined) {
+ const length = inputEl?.value?.toString().length ?? 0;
+ if (counterFormatter === undefined) {
+ this.counterString = defaultCounterFormatter(length, inputEl.maxlength);
+ } else {
+ try {
+ this.counterString = counterFormatter(length, inputEl.maxlength);
+ } catch (e) {
+ printIonError('Exception in provided `counterFormatter`.', e);
+ // Fallback to the default counter formatter when an exception happens
+ this.counterString = defaultCounterFormatter(length, inputEl.maxlength);
+ }
+ }
}
}
+ private defaultCounterFormatter(length: number, maxlength: number) {
+ return `${length} / ${maxlength}`;
+ }
+
private hasStartEl() {
const startEl = this.el.querySelector('[slot="start"]');
if (startEl !== null) {
diff --git a/core/src/components/item/readme.md b/core/src/components/item/readme.md
index eb9a91a120..ef1951465d 100644
--- a/core/src/components/item/readme.md
+++ b/core/src/components/item/readme.md
@@ -53,6 +53,10 @@ Items containing an input will highlight the bottom border of the input with a d
The highlight color changes based on the item state, but all of the states use Ionic colors by default. When focused, the input highlight will use the `primary` color. If the input is valid it will use the `success` color, and invalid inputs will use the `danger` color. See the [CSS Custom Properties](#css-custom-properties) section below for the highlight color variables.
+### Counter Formatter
+
+When using `counter`, the default behavior is to format the value that gets displayed as `itemLength / maxLength`. This behavior can be customized by passing in a formatter function to the `counterFormatter` property. See the [Usage](#usage) section for an example.
+
@@ -397,6 +401,39 @@ The highlight color changes based on the item state, but all of the states use I
```
+### Item Counter
+
+```html
+
+ Counter
+
+
+```
+
+### Item Counter Formatter
+
+```html
+
+ Counter
+
+
+
+```
+
+```typescript
+
+import { Component } from '@angular/core';
+
+@Component({…})
+export class MyComponent {
+
+ counterFormatter(inputLength: number, maxLength: number) {
+ return `${maxLength - inputLength} characters remaining`;
+ }
+}
+
+```
+
### Javascript
@@ -736,6 +773,30 @@ The highlight color changes based on the item state, but all of the states use I
```
+### Item Counter
+
+```html
+
+ Counter
+
+
+```
+
+### Item Counter Formatter
+
+```html
+
+ Counter
+
+
+
+
+
+```
+
### React
@@ -1058,6 +1119,18 @@ export const ItemExamples: React.FC = () => {
Range
+
+ {/*-- Item Counter --*/}
+
+ Counter
+
+
+
+ {/*-- Item Counter Formatter --*/}
+ `${maxLength - inputLength} characters remaining`}>
+ Counter
+
+
);
@@ -1509,6 +1582,48 @@ export class ItemExample {
}
```
+### Item Counter
+
+```tsx
+import { Component, h } from '@stencil/core';
+
+@Component({
+ tag: 'item-example',
+ styleUrl: 'item-example.css'
+})
+export class ItemExample {
+ render() {
+ return [
+
+ Counter
+
+
+ ];
+ }
+}
+```
+
+### Item Counter Formatter
+
+```tsx
+import { Component, h } from '@stencil/core';
+
+@Component({
+ tag: 'item-example',
+ styleUrl: 'item-example.css'
+})
+export class ItemExample {
+ render() {
+ return [
+ `${maxLength - inputLength} characters remaining`}>
+ Counter
+
+
+ ];
+ }
+}
+```
+
### Vue
@@ -1925,29 +2040,65 @@ export default defineComponent({
```
+### Item Counter
+
+```html
+
+
+ Counter
+
+
+
+```
+
+### Item Counter Formatter
+
+```html
+
+
+ Counter
+
+
+
+
+```
+
## Properties
-| Property | Attribute | Description | Type | Default |
-| ----------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ---------------- |
-| `button` | `button` | If `true`, a button tag will be rendered and the item will be tappable. | `boolean` | `false` |
-| `color` | `color` | The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). | `string \| undefined` | `undefined` |
-| `counter` | `counter` | 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`. | `boolean` | `false` |
-| `detail` | `detail` | If `true`, a detail arrow will appear on the item. Defaults to `false` unless the `mode` is `ios` and an `href` or `button` property is present. | `boolean \| undefined` | `undefined` |
-| `detailIcon` | `detail-icon` | The icon to use when `detail` is set to `true`. | `string` | `chevronForward` |
-| `disabled` | `disabled` | If `true`, the user cannot interact with the item. | `boolean` | `false` |
-| `download` | `download` | This attribute instructs browsers to download a URL instead of navigating to it, so the user will be prompted to save it as a local file. If the attribute has a value, it is used as the pre-filled file name in the Save prompt (the user can still change the file name if they want). | `string \| undefined` | `undefined` |
-| `fill` | `fill` | The fill for the item. If `'solid'` the item will have a background. If `'outline'` the item will be transparent with a border. Only available in `md` mode. | `"outline" \| "solid" \| undefined` | `undefined` |
-| `href` | `href` | Contains a URL or a URL fragment that the hyperlink points to. If this property is set, an anchor tag will be rendered. | `string \| undefined` | `undefined` |
-| `lines` | `lines` | How the bottom border should be displayed on the item. | `"full" \| "inset" \| "none" \| undefined` | `undefined` |
-| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
-| `rel` | `rel` | Specifies the relationship of the target object to the link object. The value is a space-separated list of [link types](https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types). | `string \| undefined` | `undefined` |
-| `routerAnimation` | -- | When using a router, it specifies the transition animation when navigating to another page using `href`. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
-| `routerDirection` | `router-direction` | When using a router, it specifies the transition direction when navigating to another page using `href`. | `"back" \| "forward" \| "root"` | `'forward'` |
-| `shape` | `shape` | The shape of the item. If "round" it will have increased border radius. | `"round" \| undefined` | `undefined` |
-| `target` | `target` | Specifies where to display the linked URL. Only applies when an `href` is provided. Special keywords: `"_blank"`, `"_self"`, `"_parent"`, `"_top"`. | `string \| undefined` | `undefined` |
-| `type` | `type` | The type of the button. Only used when an `onclick` or `button` property is present. | `"button" \| "reset" \| "submit"` | `'button'` |
+| Property | Attribute | Description | Type | Default |
+| ------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ---------------- |
+| `button` | `button` | If `true`, a button tag will be rendered and the item will be tappable. | `boolean` | `false` |
+| `color` | `color` | The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). | `string \| undefined` | `undefined` |
+| `counter` | `counter` | 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`. | `boolean` | `false` |
+| `counterFormatter` | -- | A callback used to format the counter text. By default the counter text is set to "itemLength / maxLength". | `((inputLength: number, maxLength: number) => string) \| undefined` | `undefined` |
+| `detail` | `detail` | If `true`, a detail arrow will appear on the item. Defaults to `false` unless the `mode` is `ios` and an `href` or `button` property is present. | `boolean \| undefined` | `undefined` |
+| `detailIcon` | `detail-icon` | The icon to use when `detail` is set to `true`. | `string` | `chevronForward` |
+| `disabled` | `disabled` | If `true`, the user cannot interact with the item. | `boolean` | `false` |
+| `download` | `download` | This attribute instructs browsers to download a URL instead of navigating to it, so the user will be prompted to save it as a local file. If the attribute has a value, it is used as the pre-filled file name in the Save prompt (the user can still change the file name if they want). | `string \| undefined` | `undefined` |
+| `fill` | `fill` | The fill for the item. If `'solid'` the item will have a background. If `'outline'` the item will be transparent with a border. Only available in `md` mode. | `"outline" \| "solid" \| undefined` | `undefined` |
+| `href` | `href` | Contains a URL or a URL fragment that the hyperlink points to. If this property is set, an anchor tag will be rendered. | `string \| undefined` | `undefined` |
+| `lines` | `lines` | How the bottom border should be displayed on the item. | `"full" \| "inset" \| "none" \| undefined` | `undefined` |
+| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
+| `rel` | `rel` | Specifies the relationship of the target object to the link object. The value is a space-separated list of [link types](https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types). | `string \| undefined` | `undefined` |
+| `routerAnimation` | -- | When using a router, it specifies the transition animation when navigating to another page using `href`. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
+| `routerDirection` | `router-direction` | When using a router, it specifies the transition direction when navigating to another page using `href`. | `"back" \| "forward" \| "root"` | `'forward'` |
+| `shape` | `shape` | The shape of the item. If "round" it will have increased border radius. | `"round" \| undefined` | `undefined` |
+| `target` | `target` | Specifies where to display the linked URL. Only applies when an `href` is provided. Special keywords: `"_blank"`, `"_self"`, `"_parent"`, `"_top"`. | `string \| undefined` | `undefined` |
+| `type` | `type` | The type of the button. Only used when an `onclick` or `button` property is present. | `"button" \| "reset" \| "submit"` | `'button'` |
## Slots
diff --git a/core/src/components/item/test/counter/e2e.ts b/core/src/components/item/test/counter/e2e.ts
index cfbaf1bc20..40be21c701 100644
--- a/core/src/components/item/test/counter/e2e.ts
+++ b/core/src/components/item/test/counter/e2e.ts
@@ -1,19 +1,111 @@
-import { newE2EPage } from '@stencil/core/testing';
+import { E2EPage, newE2EPage } from '@stencil/core/testing';
-test('item: counter', async () => {
- const page = await newE2EPage({
- url: '/src/components/item/test/counter?ionic:_testing=true'
+describe('item: counter', () => {
+
+ it('should match existing visual screenshots', async () => {
+ const page = await newE2EPage({
+ url: '/src/components/item/test/counter?ionic:_testing=true'
+ });
+
+ const compare = await page.compareScreenshot();
+ expect(compare).toMatchScreenshot();
});
- const compare = await page.compareScreenshot();
- expect(compare).toMatchScreenshot();
-});
+ describe('custom formatter', () => {
+
+ let page: E2EPage;
+
+ beforeEach(async () => {
+ page = await newE2EPage({
+ url: '/src/components/item/test/counter?ionic:_testing=true'
+ });
+ });
+
+ it('should format on load', async () => {
+ const itemCounter = await page.find('#customFormatter >>> .item-counter');
+
+ expect(itemCounter.textContent).toBe('20 characters left');
+ });
+
+ it('should format on input', async () => {
+ const input = await page.find('#customFormatter ion-input');
+
+ await input.click();
+ await input.type('abcde');
+
+ await page.waitForChanges();
+
+ const itemCounter = await page.find('#customFormatter >>> .item-counter');
+
+ expect(itemCounter.textContent).toBe('15 characters left');
+ });
+
+ it('should format after changing the counterFormatter', async () => {
+ let itemCounter = await page.find('#customFormatter >>> .item-counter');
+
+ expect(itemCounter.textContent).toBe('20 characters left');
+
+ await page.$eval('#customFormatter', (el: any) => {
+ el.counterFormatter = () => {
+ return 'test label';
+ };
+ });
+ await page.waitForChanges();
+
+ itemCounter = await page.find('#customFormatter >>> .item-counter');
+
+ expect(itemCounter.textContent).toBe('test label');
+ });
+
+ describe('when an exception occurs', () => {
+
+ const logs = [];
+
+ beforeEach(async () => {
+ page = await newE2EPage({
+ html: `
+
+
+ `
+ });
+
+ page.on('console', ev => {
+ if (ev.type() === 'error') {
+ logs.push(ev.text());
+ }
+ });
+
+ let itemCounter = await page.find('ion-item >>> .item-counter');
+
+ expect(itemCounter.textContent).toBe('0 / 20');
+
+ await page.$eval('ion-item', (el: any) => {
+ el.counterFormatter = () => {
+ throw new Error('This is an expected error');
+ };
+ });
+ await page.waitForChanges();
+
+ });
+
+ it('should default the formatting to length / maxlength', async () => {
+ const input = await page.find('ion-input');
+
+ await input.click();
+ await input.type('abcde');
+
+ const itemCounter = await page.find('ion-item >>> .item-counter');
+
+ expect(itemCounter.textContent).toBe('5 / 20');
+ });
+
+ it('should log an error', () => {
+ expect(logs.length).toBeGreaterThan(0);
+ expect(logs[0]).toMatch('[Ionic Error]: Exception in provided `counterFormatter`.');
+ });
+
+ });
-test('item: counter-rtl', async () => {
- const page = await newE2EPage({
- url: '/src/components/item/test/counter?ionic:_testing=true&rtl=true'
});
- const compare = await page.compareScreenshot();
- expect(compare).toMatchScreenshot();
-});
+})
diff --git a/core/src/components/item/test/counter/index.html b/core/src/components/item/test/counter/index.html
index 841578862b..8510254020 100644
--- a/core/src/components/item/test/counter/index.html
+++ b/core/src/components/item/test/counter/index.html
@@ -26,17 +26,29 @@
- Counter
+ Counter
- Counter with value
+ Counter with value
+
+
+ Counter with custom formatter
+
+
+
+