feat(item): counter formatter to customize counter text display (#24336)

Resolves #24327
This commit is contained in:
Sean Perkins
2022-03-14 16:38:37 -04:00
committed by GitHub
parent bc4cad3e7b
commit 171020e9d2
16 changed files with 497 additions and 52 deletions

View File

@ -834,13 +834,13 @@ export declare interface IonItem extends Components.IonItem {}
@ProxyCmp({ @ProxyCmp({
defineCustomElementFn: undefined, 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({ @Component({
selector: 'ion-item', selector: 'ion-item',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>', template: '<ng-content></ng-content>',
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 { export class IonItem {
protected el: HTMLElement; protected el: HTMLElement;

View File

@ -557,6 +557,7 @@ ion-item,shadow
ion-item,prop,button,boolean,false,false,false ion-item,prop,button,boolean,false,false,false
ion-item,prop,color,string | undefined,undefined,false,true ion-item,prop,color,string | undefined,undefined,false,true
ion-item,prop,counter,boolean,false,false,false 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,detail,boolean | undefined,undefined,false,false
ion-item,prop,detailIcon,string,chevronForward,false,false ion-item,prop,detailIcon,string,chevronForward,false,false
ion-item,prop,disabled,boolean,false,false,false ion-item,prop,disabled,boolean,false,false,false

View File

@ -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 { 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 { IonicSafeString } from "./utils/sanitization";
import { AlertAttributes } from "./components/alert/alert-interface"; 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 { PickerColumnItem } from "./components/picker-column-internal/picker-column-internal-interfaces";
import { PickerInternalChangeEventDetail } from "./components/picker-internal/picker-internal-interfaces"; import { PickerInternalChangeEventDetail } from "./components/picker-internal/picker-internal-interfaces";
import { PinFormatter } from "./components/range/range-interface"; 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`. * 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; "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. * 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`. * 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; "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. * 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.
*/ */

View File

@ -0,0 +1,2 @@
export type CounterFormatter = (inputLength: number, maxLength: number) => string;

View File

@ -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 { chevronForward } from 'ionicons/icons';
import { getIonMode } from '../../global/ionic-global'; import { getIonMode } from '../../global/ionic-global';
@ -8,6 +9,8 @@ import { raf } from '../../utils/helpers';
import { createColorClasses, hostContext, openURL } from '../../utils/theme'; import { createColorClasses, hostContext, openURL } from '../../utils/theme';
import { InputChangeEventDetail } from '../input/input-interface'; import { InputChangeEventDetail } from '../input/input-interface';
import { CounterFormatter } from './item-interface';
/** /**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * @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'; @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; @State() counterString: string | null | undefined;
@Watch('counterFormatter')
counterFormatterChanged() {
this.updateCounterOutput(this.getFirstInput());
}
@Listen('ionChange') @Listen('ionChange')
handleIonChange(ev: CustomEvent<InputChangeEventDetail>) { handleIonChange(ev: CustomEvent<InputChangeEventDetail>) {
if (this.counter && ev.target === this.getFirstInput()) { if (this.counter && ev.target === this.getFirstInput()) {
@ -296,12 +310,27 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
} }
private updateCounterOutput(inputEl: HTMLIonInputElement | HTMLIonTextareaElement) { private updateCounterOutput(inputEl: HTMLIonInputElement | HTMLIonTextareaElement) {
if (this.counter && !this.multipleInputs && inputEl?.maxlength !== undefined) { const { counter, counterFormatter, defaultCounterFormatter } = this;
const length = inputEl?.value?.toString().length ?? '0'; if (counter && !this.multipleInputs && inputEl?.maxlength !== undefined) {
this.counterString = `${length} / ${inputEl.maxlength}`; 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() { private hasStartEl() {
const startEl = this.el.querySelector('[slot="start"]'); const startEl = this.el.querySelector('[slot="start"]');
if (startEl !== null) { if (startEl !== null) {

View File

@ -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. 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.
<!-- Auto Generated Below --> <!-- Auto Generated Below -->
@ -397,6 +401,39 @@ The highlight color changes based on the item state, but all of the states use I
</ion-item> </ion-item>
``` ```
### Item Counter
```html
<ion-item [counter]="true">
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
```
### Item Counter Formatter
```html
<ion-item [counter]="true" [counterFormatter]="counterFormatter">
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
```
```typescript
import { Component } from '@angular/core';
@Component({})
export class MyComponent {
counterFormatter(inputLength: number, maxLength: number) {
return `${maxLength - inputLength} characters remaining`;
}
}
```
### Javascript ### Javascript
@ -736,6 +773,30 @@ The highlight color changes based on the item state, but all of the states use I
</ion-item> </ion-item>
``` ```
### Item Counter
```html
<ion-item counter="true">
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
```
### Item Counter Formatter
```html
<ion-item counter="true" id="custom-item">
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
<script>
const customItem = document.querySelector('#custom-item');
customItem.counterFormatter = (inputLength, maxLength) => `${maxLength - inputLength} characters remaining`;
</script>
```
### React ### React
@ -1058,6 +1119,18 @@ export const ItemExamples: React.FC = () => {
<IonLabel>Range</IonLabel> <IonLabel>Range</IonLabel>
<IonRange></IonRange> <IonRange></IonRange>
</IonItem> </IonItem>
{/*-- Item Counter --*/}
<IonItem counter={true}>
<IonLabel>Counter</IonLabel>
<IonInput maxlength="20"></IonInput>
</IonItem>
{/*-- Item Counter Formatter --*/}
<IonItem counter={true} counterFormatter={(inputLength, maxLength) => `${maxLength - inputLength} characters remaining`}>
<IonLabel>Counter</IonLabel>
<IonInput maxlength="20"></IonInput>
</IonItem>
</IonContent> </IonContent>
</IonPage> </IonPage>
); );
@ -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 [
<ion-item counter={true}>
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
];
}
}
```
### Item Counter Formatter
```tsx
import { Component, h } from '@stencil/core';
@Component({
tag: 'item-example',
styleUrl: 'item-example.css'
})
export class ItemExample {
render() {
return [
<ion-item counter="true" counterFormatter={(inputLength, maxLength) => `${maxLength - inputLength} characters remaining`}>
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
];
}
}
```
### Vue ### Vue
@ -1925,29 +2040,65 @@ export default defineComponent({
</script> </script>
``` ```
### Item Counter
```html
<template>
<ion-item :counter="true">
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
</template>
```
### Item Counter Formatter
```html
<template>
<ion-item :counter="true" :counter-formatter="counterFormatter">
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
</template>
<script lang="ts">
import { IonInput, IonItem, IonLabel } from '@ionic/vue';
import { defineComponent } from 'vue';
export default defineComponent({
components: { IonItem, IonLabel, IonInput },
setup() {
const counterFormatter = (inputLength: number, maxLength: number) => `${maxLength - inputLength} characters remaining`;
return { counterFormatter };
}
});
</script>
```
## Properties ## Properties
| Property | Attribute | Description | Type | Default | | Property | Attribute | Description | Type | Default |
| ----------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ---------------- | | ------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ---------------- |
| `button` | `button` | If `true`, a button tag will be rendered and the item will be tappable. | `boolean` | `false` | | `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` | | `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` | | `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` | | `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` |
| `detailIcon` | `detail-icon` | The icon to use when `detail` is set to `true`. | `string` | `chevronForward` | | `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` |
| `disabled` | `disabled` | If `true`, the user cannot interact with the item. | `boolean` | `false` | | `detailIcon` | `detail-icon` | The icon to use when `detail` is set to `true`. | `string` | `chevronForward` |
| `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` | | `disabled` | `disabled` | If `true`, the user cannot interact with the item. | `boolean` | `false` |
| `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` | | `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` |
| `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` | | `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` |
| `lines` | `lines` | How the bottom border should be displayed on the item. | `"full" \| "inset" \| "none" \| 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` |
| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` | | `lines` | `lines` | How the bottom border should be displayed on the item. | `"full" \| "inset" \| "none" \| undefined` | `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` | | `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `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` | | `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` |
| `routerDirection` | `router-direction` | When using a router, it specifies the transition direction when navigating to another page using `href`. | `"back" \| "forward" \| "root"` | `'forward'` | | `routerAnimation` | -- | When using a router, it specifies the transition animation when navigating to another page using `href`. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
| `shape` | `shape` | The shape of the item. If "round" it will have increased border radius. | `"round" \| 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'` |
| `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` | | `shape` | `shape` | The shape of the item. If "round" it will have increased border radius. | `"round" \| undefined` | `undefined` |
| `type` | `type` | The type of the button. Only used when an `onclick` or `button` property is present. | `"button" \| "reset" \| "submit"` | `'button'` | | `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 ## Slots

View File

@ -1,19 +1,111 @@
import { newE2EPage } from '@stencil/core/testing'; import { E2EPage, newE2EPage } from '@stencil/core/testing';
test('item: counter', async () => { describe('item: counter', () => {
const page = await newE2EPage({
url: '/src/components/item/test/counter?ionic:_testing=true' 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(); describe('custom formatter', () => {
expect(compare).toMatchScreenshot();
}); 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: `
<ion-item counter="true"">
<ion-input maxlength="20" value=""></ion-input>
</ion-item>`
});
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();
});

View File

@ -26,17 +26,29 @@
<ion-list> <ion-list>
<ion-item counter="true"> <ion-item counter="true">
<ion-label>Counter</ion-label> <ion-label position="stacked">Counter</ion-label>
<ion-input maxlength="20"></ion-input> <ion-input maxlength="20"></ion-input>
</ion-item> </ion-item>
<ion-item counter="true"> <ion-item counter="true">
<ion-label>Counter with value</ion-label> <ion-label position="stacked">Counter with value</ion-label>
<ion-input maxlength="20" value="some value"></ion-input> <ion-input maxlength="20" value="some value"></ion-input>
</ion-item> </ion-item>
<ion-item counter="true" id="customFormatter">
<ion-label position="stacked">Counter with custom formatter</ion-label>
<ion-input maxlength="20" value=""></ion-input>
</ion-item>
</ion-list> </ion-list>
</ion-content> </ion-content>
</ion-app> </ion-app>
<script>
const customFormatter = document.getElementById('customFormatter');
customFormatter.counterFormatter = (inputLength, maxLength) => {
return `${maxLength - inputLength} characters left`;
};
</script>
</html> </html>

View File

@ -333,3 +333,36 @@
<ion-range></ion-range> <ion-range></ion-range>
</ion-item> </ion-item>
``` ```
### Item Counter
```html
<ion-item [counter]="true">
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
```
### Item Counter Formatter
```html
<ion-item [counter]="true" [counterFormatter]="counterFormatter">
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
```
```typescript
import { Component } from '@angular/core';
@Component({})
export class MyComponent {
counterFormatter(inputLength: number, maxLength: number) {
return `${maxLength - inputLength} characters remaining`;
}
}
```

View File

@ -333,3 +333,27 @@
<ion-range></ion-range> <ion-range></ion-range>
</ion-item> </ion-item>
``` ```
### Item Counter
```html
<ion-item counter="true">
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
```
### Item Counter Formatter
```html
<ion-item counter="true" id="custom-item">
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
<script>
const customItem = document.querySelector('#custom-item');
customItem.counterFormatter = (inputLength, maxLength) => `${maxLength - inputLength} characters remaining`;
</script>
```

View File

@ -317,6 +317,18 @@ export const ItemExamples: React.FC = () => {
<IonLabel>Range</IonLabel> <IonLabel>Range</IonLabel>
<IonRange></IonRange> <IonRange></IonRange>
</IonItem> </IonItem>
{/*-- Item Counter --*/}
<IonItem counter={true}>
<IonLabel>Counter</IonLabel>
<IonInput maxlength="20"></IonInput>
</IonItem>
{/*-- Item Counter Formatter --*/}
<IonItem counter={true} counterFormatter={(inputLength, maxLength) => `${maxLength - inputLength} characters remaining`}>
<IonLabel>Counter</IonLabel>
<IonInput maxlength="20"></IonInput>
</IonItem>
</IonContent> </IonContent>
</IonPage> </IonPage>
); );

View File

@ -438,4 +438,46 @@ 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 [
<ion-item counter={true}>
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
];
}
}
```
### Item Counter Formatter
```tsx
import { Component, h } from '@stencil/core';
@Component({
tag: 'item-example',
styleUrl: 'item-example.css'
})
export class ItemExample {
render() {
return [
<ion-item counter="true" counterFormatter={(inputLength, maxLength) => `${maxLength - inputLength} characters remaining`}>
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
];
}
}
``` ```

View File

@ -410,3 +410,38 @@ export default defineComponent({
}); });
</script> </script>
``` ```
### Item Counter
```html
<template>
<ion-item :counter="true">
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
</template>
```
### Item Counter Formatter
```html
<template>
<ion-item :counter="true" :counter-formatter="counterFormatter">
<ion-label>Counter</ion-label>
<ion-input maxlength="20"></ion-input>
</ion-item>
</template>
<script lang="ts">
import { IonInput, IonItem, IonLabel } from '@ionic/vue';
import { defineComponent } from 'vue';
export default defineComponent({
components: { IonItem, IonLabel, IonInput },
setup() {
const counterFormatter = (inputLength: number, maxLength: number) => `${maxLength - inputLength} characters remaining`;
return { counterFormatter };
}
});
</script>
```

View File

@ -11,6 +11,7 @@ export * from './components/checkbox/checkbox-interface';
export * from './components/datetime/datetime-interface'; export * from './components/datetime/datetime-interface';
export * from './components/infinite-scroll/infinite-scroll-interface'; export * from './components/infinite-scroll/infinite-scroll-interface';
export * from './components/input/input-interface'; export * from './components/input/input-interface';
export * from './components/item/item-interface';
export * from './components/item-sliding/item-sliding-interface'; export * from './components/item-sliding/item-sliding-interface';
export * from './components/loading/loading-interface'; export * from './components/loading/loading-interface';
export * from './components/menu/menu-interface'; export * from './components/menu/menu-interface';
@ -43,13 +44,13 @@ export { Gesture, GestureConfig, GestureDetail } from './utils/gesture';
// From: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete // From: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete
export type AutocompleteTypes = ( export type AutocompleteTypes = (
| 'on' | 'off' | 'name' | 'honorific-prefix' | 'given-name' | 'additional-name' | 'family-name' | 'honorific-suffix' | 'on' | 'off' | 'name' | 'honorific-prefix' | 'given-name' | 'additional-name' | 'family-name' | 'honorific-suffix'
| 'nickname' | 'email' | 'username' | 'new-password' | 'current-password' | 'one-time-code' | 'organization-title' | 'organization' | 'nickname' | 'email' | 'username' | 'new-password' | 'current-password' | 'one-time-code' | 'organization-title' | 'organization'
| 'street-address' | 'address-line1' | 'address-line2' | 'address-line3' | 'address-level4' | 'address-level3' | 'address-level2' | 'street-address' | 'address-line1' | 'address-line2' | 'address-line3' | 'address-level4' | 'address-level3' | 'address-level2'
| 'address-level1' | 'country' | 'country-name' | 'postal-code' | 'cc-name' | 'cc-given-name' | 'cc-additional-name' | 'cc-family-name' | 'address-level1' | 'country' | 'country-name' | 'postal-code' | 'cc-name' | 'cc-given-name' | 'cc-additional-name' | 'cc-family-name'
| 'cc-family-name' | 'cc-number' | 'cc-exp' | 'cc-exp-month' | 'cc-exp-year' | 'cc-csc' | 'cc-type' | 'transaction-currency' | 'transaction-amount' | 'cc-family-name' | 'cc-number' | 'cc-exp' | 'cc-exp-month' | 'cc-exp-year' | 'cc-csc' | 'cc-type' | 'transaction-currency' | 'transaction-amount'
| 'language' | 'bday' | 'bday-day' | 'bday-month' | 'bday-year' | 'sex' | 'tel' | 'tel-country-code' | 'tel-national' | 'tel-area-code' | 'tel-local' | 'language' | 'bday' | 'bday-day' | 'bday-month' | 'bday-year' | 'sex' | 'tel' | 'tel-country-code' | 'tel-national' | 'tel-area-code' | 'tel-local'
| 'tel-extension' | 'impp' | 'url' | 'photo'); | 'tel-extension' | 'impp' | 'url' | 'photo');
export type TextFieldTypes = 'date' | 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' | 'time' | 'week' | 'month' | 'datetime-local'; export type TextFieldTypes = 'date' | 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' | 'time' | 'week' | 'month' | 'datetime-local';
@ -59,7 +60,7 @@ export type Color = PredefinedColors | string;
export type Mode = "ios" | "md"; export type Mode = "ios" | "md";
export type ComponentTags = string; export type ComponentTags = string;
export type ComponentRef = Function | HTMLElement | string | null; export type ComponentRef = Function | HTMLElement | string | null;
export type ComponentProps<T = null> = {[key: string]: any}; export type ComponentProps<T = null> = { [key: string]: any };
export type CssClassMap = { [className: string]: boolean }; export type CssClassMap = { [className: string]: boolean };
export type BackButtonEvent = CustomEvent<BackButtonEventDetail>; export type BackButtonEvent = CustomEvent<BackButtonEventDetail>;
@ -84,12 +85,12 @@ export { NavComponentWithProps } from "./components/nav/nav-interface";
declare module "./components" { declare module "./components" {
export namespace Components { export namespace Components {
export interface IonIcon extends IoniconsComponents.IonIcon{} export interface IonIcon extends IoniconsComponents.IonIcon { }
} }
} }
declare module "./components" { declare module "./components" {
export namespace JSX { export namespace JSX {
export interface IonIcon extends IoniconsJSX.IonIcon {} export interface IonIcon extends IoniconsJSX.IonIcon { }
} }
} }

View File

@ -262,7 +262,8 @@ export const config: Config = {
pixelmatchThreshold: 0.05, pixelmatchThreshold: 0.05,
waitBeforeScreenshot: 20, waitBeforeScreenshot: 20,
moduleNameMapper: { moduleNameMapper: {
"@utils/test": ["<rootDir>/src/utils/test/utils"] "@utils/test": ["<rootDir>/src/utils/test/utils"],
"@utils/logging": ["<rootDir>/src/utils/logging"]
}, },
emulate: [ emulate: [
{ {

View File

@ -429,7 +429,8 @@ export const IonItem = /*@__PURE__*/ defineContainer<JSX.IonItem>('ion-item', de
'routerAnimation', 'routerAnimation',
'routerDirection', 'routerDirection',
'target', 'target',
'type' 'type',
'counterFormatter'
]); ]);