feat(select): add compareWith property (#17358)

* feat(select): add compareWith property

* style(select): fix lint errors

* test(select): move tests from preview to basic

* refactor(select): improve parameter names in compareOptions method

* chore(): add react usage docs

* chore(): update var names, update examples

* rerun build

* add doc on compareWith
This commit is contained in:
Zachary Keeton
2019-03-01 16:46:42 -03:00
committed by Liam DeBeasi
parent 14dd871a85
commit 69ecebb159
11 changed files with 388 additions and 26 deletions

View File

@ -720,7 +720,7 @@ export class IonSegmentButton {
proxyInputs(IonSegmentButton, ['mode', 'checked', 'disabled', 'layout', 'value']); proxyInputs(IonSegmentButton, ['mode', 'checked', 'disabled', 'layout', 'value']);
export declare interface IonSelect extends StencilComponents<'IonSelect'> {} export declare interface IonSelect extends StencilComponents<'IonSelect'> {}
@Component({ selector: 'ion-select', changeDetection: 0, template: '<ng-content></ng-content>', inputs: ['mode', 'disabled', 'cancelText', 'okText', 'placeholder', 'name', 'selectedText', 'multiple', 'interface', 'interfaceOptions', 'value'] }) @Component({ selector: 'ion-select', changeDetection: 0, template: '<ng-content></ng-content>', inputs: ['mode', 'disabled', 'cancelText', 'okText', 'placeholder', 'name', 'selectedText', 'multiple', 'interface', 'interfaceOptions', 'compareWith', 'value'] })
export class IonSelect { export class IonSelect {
ionChange!: EventEmitter<CustomEvent>; ionChange!: EventEmitter<CustomEvent>;
ionCancel!: EventEmitter<CustomEvent>; ionCancel!: EventEmitter<CustomEvent>;
@ -734,7 +734,7 @@ export class IonSelect {
} }
} }
proxyMethods(IonSelect, ['open']); proxyMethods(IonSelect, ['open']);
proxyInputs(IonSelect, ['mode', 'disabled', 'cancelText', 'okText', 'placeholder', 'name', 'selectedText', 'multiple', 'interface', 'interfaceOptions', 'value']); proxyInputs(IonSelect, ['mode', 'disabled', 'cancelText', 'okText', 'placeholder', 'name', 'selectedText', 'multiple', 'interface', 'interfaceOptions', 'compareWith', 'value']);
export declare interface IonSelectOption extends StencilComponents<'IonSelectOption'> {} export declare interface IonSelectOption extends StencilComponents<'IonSelectOption'> {}
@Component({ selector: 'ion-select-option', changeDetection: 0, template: '<ng-content></ng-content>', inputs: ['disabled', 'selected', 'value'] }) @Component({ selector: 'ion-select-option', changeDetection: 0, template: '<ng-content></ng-content>', inputs: ['disabled', 'selected', 'value'] })

View File

@ -954,6 +954,7 @@ ion-select-option,prop,value,any,undefined,false,false
ion-select,shadow ion-select,shadow
ion-select,prop,cancelText,string,'Cancel',false,false ion-select,prop,cancelText,string,'Cancel',false,false
ion-select,prop,compareWith,((currentValue: any, compareValue: any) => boolean) | null | string | undefined,undefined,false,false
ion-select,prop,disabled,boolean,false,false,false ion-select,prop,disabled,boolean,false,false,false
ion-select,prop,interface,"action-sheet" | "alert" | "popover",'alert',false,false ion-select,prop,interface,"action-sheet" | "alert" | "popover",'alert',false,false
ion-select,prop,interfaceOptions,any,{},false,false ion-select,prop,interfaceOptions,any,{},false,false

View File

@ -77,6 +77,9 @@ import {
import { import {
EventEmitter, EventEmitter,
} from '@stencil/core'; } from '@stencil/core';
import {
SelectCompareFn,
} from './components/select/select-interface';
export namespace Components { export namespace Components {
@ -3993,6 +3996,10 @@ export namespace Components {
*/ */
'cancelText': string; 'cancelText': string;
/** /**
* A property name or function used to compare object values
*/
'compareWith'?: string | SelectCompareFn | null;
/**
* If `true`, the user cannot interact with the select. * If `true`, the user cannot interact with the select.
*/ */
'disabled': boolean; 'disabled': boolean;
@ -4043,6 +4050,10 @@ export namespace Components {
*/ */
'cancelText'?: string; 'cancelText'?: string;
/** /**
* A property name or function used to compare object values
*/
'compareWith'?: string | SelectCompareFn | null;
/**
* If `true`, the user cannot interact with the select. * If `true`, the user cannot interact with the select.
*/ */
'disabled'?: boolean; 'disabled'?: boolean;

View File

@ -23,6 +23,11 @@ By adding the `multiple` attribute to select, users are able to select multiple
Note: the `action-sheet` and `popover` interfaces will not work with multiple selection. Note: the `action-sheet` and `popover` interfaces will not work with multiple selection.
## Object Value References
When using objects for select values, it is possible for the identities of these objects to change if they are coming from a server or database, while the selected value's identity remains the same. For example, this can occur when an existing record with the desired object value is loaded into the select, but the newly retrieved select options now have different identities. This will result in the select appearing to have no value at all, even though the original selection in still intact.
By default, the select uses object equality (`===`) to determine if an option is selected. This can be overridden by providing a property name or a function to the `compareWith` property.
## Select Buttons ## Select Buttons
@ -104,6 +109,56 @@ Since select uses the alert, action sheet and popover interfaces, options can be
</ion-list> </ion-list>
``` ```
## Objects as Values
```html
<ion-list>
<ion-list-header>Objects as Values (compareWith)</ion-list-header>
<ion-item>
<ion-label>Users</ion-label>
<ion-select [compareWith]="compareWith">
<ion-select-option *ngFor="let user of users">{{user.first + ' ' + user.last}}</ion-select-option>
</ion-select>
</ion-item>
</ion-list>
```
```typescript
import { Component } from '@angular/core';
@Component({
selector: 'select-example',
templateUrl: 'select-example.html',
styleUrls: ['./select-example.css'],
})
export class SelectExample {
users: any[] = [
{
id: 1,
first: 'Alice',
last: 'Smith',
},
{
id: 2,
first: 'Bob',
last: 'Davis',
},
{
id: 3,
first: 'Charlie',
last: 'Rosenburg',
}
];
compareWithFn = (o1, o2) => {
return o1 && o2 ? o1.id === o2.id : o1 === o2;
};
compareWith = compareWithFn;
}
```
## Interface Options ## Interface Options
```html ```html
@ -243,6 +298,56 @@ export class SelectExample {
</ion-list> </ion-list>
``` ```
## Objects as Values
```html
<ion-list>
<ion-list-header>Objects as Values (compareWith)</ion-list-header>
<ion-item>
<ion-label>Users</ion-label>
<ion-select id="objectSelectCompareWith"></ion-select>
</ion-item>
</ion-list>
```
```javascript
let objectOptions = [
{
id: 1,
first: 'Alice',
last: 'Smith',
},
{
id: 2,
first: 'Bob',
last: 'Davis',
},
{
id: 3,
first: 'Charlie',
last: 'Rosenburg',
}
];
let compareWithFn = (o1, o2) => {
return o1 && o2 ? o1.id === o2.id : o1 === o2;
};
let objectSelectElement = document.getElementById('objectSelectCompareWith');
objectSelectElement.compareWith = compareWithFn;
objectOptions.forEach((option, i) => {
let selectOption = document.createElement('ion-select-option');
selectOption.value = option;
selectOption.textContent = option.first + ' ' + option.last;
selectOption.selected = (i === 0);
objectSelectElement.appendChild(selectOption)
});
}
```
## Interface Options ## Interface Options
```html ```html
@ -341,6 +446,28 @@ const customActionSheetOptions = {
subHeader: 'Select your favorite color' subHeader: 'Select your favorite color'
}; };
const objectOptions = [
{
id: 1,
first: 'Alice',
last: 'Smith'
},
{
id: 2,
first: 'Bob',
last: 'Davis'
},
{
id: 3,
first: 'Charlie',
last: 'Rosenburg'
}
];
const compareWith = (o1: any, o2: any) => {
return o1 && o2 ? o1.id === o2.id : o1 === o2;
};
const Example: React.SFC<{}> = () => ( const Example: React.SFC<{}> = () => (
<> <>
## Single Selection ## Single Selection
@ -401,6 +528,20 @@ const Example: React.SFC<{}> = () => (
</IonSelect> </IonSelect>
</IonItem> </IonItem>
</IonList> </IonList>
## Objects as Values
<IonList>
<IonListHeader>Objects as Values (compareWith)</IonListHeader>
<IonItem>
<IonLabel>Users</IonLabel>
<IonSelect compareWith={compareWith}>
{objectOptions.map((object, i) => {
return <IonSelectOption key={object.id} value={object.id}>{object.first} {object.last}</IonSelectOption>
})}
</IonSelect>
</IonItem>
</IonList>
## Interface Options ## Interface Options
@ -597,19 +738,20 @@ export default Example;
## Properties ## Properties
| Property | Attribute | Description | Type | Default | | Property | Attribute | Description | Type | Default |
| ------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -------------- | | ------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------- |
| `cancelText` | `cancel-text` | The text to display on the cancel button. | `string` | `'Cancel'` | | `cancelText` | `cancel-text` | The text to display on the cancel button. | `string` | `'Cancel'` |
| `disabled` | `disabled` | If `true`, the user cannot interact with the select. | `boolean` | `false` | | `compareWith` | `compare-with` | A property name or function used to compare object values | `((currentValue: any, compareValue: any) => boolean) \| null \| string \| undefined` | `undefined` |
| `interface` | `interface` | The interface the select should use: `action-sheet`, `popover` or `alert`. | `"action-sheet" \| "alert" \| "popover"` | `'alert'` | | `disabled` | `disabled` | If `true`, the user cannot interact with the select. | `boolean` | `false` |
| `interfaceOptions` | `interface-options` | Any additional options that the `alert`, `action-sheet` or `popover` interface can take. See the [AlertController API docs](../../alert/AlertController/#create), the [ActionSheetController API docs](../../action-sheet/ActionSheetController/#create) and the [PopoverController API docs](../../popover/PopoverController/#create) for the create options for each interface. | `any` | `{}` | | `interface` | `interface` | The interface the select should use: `action-sheet`, `popover` or `alert`. | `"action-sheet" \| "alert" \| "popover"` | `'alert'` |
| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` | | `interfaceOptions` | `interface-options` | Any additional options that the `alert`, `action-sheet` or `popover` interface can take. See the [AlertController API docs](../../alert/AlertController/#create), the [ActionSheetController API docs](../../action-sheet/ActionSheetController/#create) and the [PopoverController API docs](../../popover/PopoverController/#create) for the create options for each interface. | `any` | `{}` |
| `multiple` | `multiple` | If `true`, the select can accept multiple values. | `boolean` | `false` | | `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
| `name` | `name` | The name of the control, which is submitted with the form data. | `string` | `this.inputId` | | `multiple` | `multiple` | If `true`, the select can accept multiple values. | `boolean` | `false` |
| `okText` | `ok-text` | The text to display on the ok button. | `string` | `'OK'` | | `name` | `name` | The name of the control, which is submitted with the form data. | `string` | `this.inputId` |
| `placeholder` | `placeholder` | The text to display when the select is empty. | `null \| string \| undefined` | `undefined` | | `okText` | `ok-text` | The text to display on the ok button. | `string` | `'OK'` |
| `selectedText` | `selected-text` | The text to display instead of the selected option's value. | `null \| string \| undefined` | `undefined` | | `placeholder` | `placeholder` | The text to display when the select is empty. | `null \| string \| undefined` | `undefined` |
| `value` | `value` | the value of the select. | `any` | `undefined` | | `selectedText` | `selected-text` | The text to display instead of the selected option's value. | `null \| string \| undefined` | `undefined` |
| `value` | `value` | the value of the select. | `any` | `undefined` |
## Events ## Events

View File

@ -1,5 +1,7 @@
export type SelectInterface = 'action-sheet' | 'popover' | 'alert'; export type SelectInterface = 'action-sheet' | 'popover' | 'alert';
export type SelectCompareFn = (currentValue: any, compareValue: any) => boolean;
export interface SelectChangeEventDetail { export interface SelectChangeEventDetail {
value: any | any[] | undefined | null; value: any | any[] | undefined | null;
} }

View File

@ -4,6 +4,8 @@ import { ActionSheetButton, ActionSheetOptions, AlertOptions, CssClassMap, Mode,
import { findItemLabel, renderHiddenInput } from '../../utils/helpers'; import { findItemLabel, renderHiddenInput } from '../../utils/helpers';
import { hostContext } from '../../utils/theme'; import { hostContext } from '../../utils/theme';
import { SelectCompareFn } from './select-interface';
@Component({ @Component({
tag: 'ion-select', tag: 'ion-select',
styleUrls: { styleUrls: {
@ -82,6 +84,11 @@ export class Select implements ComponentInterface {
*/ */
@Prop() interfaceOptions: any = {}; @Prop() interfaceOptions: any = {};
/**
* A property name or function used to compare object values
*/
@Prop() compareWith?: string | SelectCompareFn | null;
/** /**
* the value of the select. * the value of the select.
*/ */
@ -346,7 +353,7 @@ export class Select implements ComponentInterface {
// iterate all options, updating the selected prop // iterate all options, updating the selected prop
let canSelect = true; let canSelect = true;
for (const selectOption of this.childOpts) { for (const selectOption of this.childOpts) {
const selected = canSelect && isOptionSelected(this.value, selectOption.value); const selected = canSelect && isOptionSelected(this.value, selectOption.value, this.compareWith);
selectOption.selected = selected; selectOption.selected = selected;
// if current option is selected and select is single-option, we can't select // if current option is selected and select is single-option, we can't select
@ -370,7 +377,7 @@ export class Select implements ComponentInterface {
if (selectedText != null && selectedText !== '') { if (selectedText != null && selectedText !== '') {
return selectedText; return selectedText;
} }
return generateText(this.childOpts, this.value); return generateText(this.childOpts, this.value, this.compareWith);
} }
private setFocus() { private setFocus() {
@ -468,33 +475,45 @@ function parseValue(value: any) {
return value.toString(); return value.toString();
} }
function isOptionSelected(currentValue: any[] | any, optionValue: any) { function isOptionSelected(currentValue: any[] | any, compareValue: any, compareWith?: string | SelectCompareFn | null) {
if (currentValue === undefined) { if (currentValue === undefined) {
return false; return false;
} }
if (Array.isArray(currentValue)) { if (Array.isArray(currentValue)) {
return currentValue.includes(optionValue); return currentValue.some(val => compareOptions(val, compareValue, compareWith));
} else { } else {
return currentValue === optionValue; return compareOptions(currentValue, compareValue, compareWith);
} }
} }
function generateText(opts: HTMLIonSelectOptionElement[], value: any | any[]) { function compareOptions(currentValue: any, compareValue: any, compareWith?: string | SelectCompareFn | null): boolean {
if (typeof compareWith === 'function') {
return compareWith(currentValue, compareValue);
} else if (typeof compareWith === 'string') {
return currentValue[compareWith] === compareValue[compareWith];
} else {
return currentValue === compareValue;
}
}
function generateText(opts: HTMLIonSelectOptionElement[], value: any | any[], compareWith?: string | SelectCompareFn | null) {
if (value === undefined) { if (value === undefined) {
return ''; return '';
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value return value
.map(v => textForValue(opts, v)) .map(v => textForValue(opts, v, compareWith))
.filter(opt => opt !== null) .filter(opt => opt !== null)
.join(', '); .join(', ');
} else { } else {
return textForValue(opts, value) || ''; return textForValue(opts, value, compareWith) || '';
} }
} }
function textForValue(opts: HTMLIonSelectOptionElement[], value: any): string | null { function textForValue(opts: HTMLIonSelectOptionElement[], value: any, compareWith?: string | SelectCompareFn | null): string | null {
const selectOpt = opts.find(opt => opt.value === value); const selectOpt = opts.find(opt => {
return compareOptions(opt.value, value, compareWith);
});
return selectOpt return selectOpt
? selectOpt.textContent ? selectOpt.textContent
: null; : null;

View File

@ -55,6 +55,15 @@
</ion-list> </ion-list>
<ion-list>
<ion-list-header>Object Values with trackBy</ion-list-header>
<ion-item>
<ion-label>Users</ion-label>
<ion-select id="objectSelectCompareWith"></ion-select>
</ion-item>
</ion-list>
<ion-list> <ion-list>
<ion-list-header>Select - Custom Interface Options</ion-list-header> <ion-list-header>Select - Custom Interface Options</ion-list-header>
@ -280,6 +289,48 @@
</style> </style>
<script> <script>
let objectOptions = [
{
id: 1,
first: 'Alice',
last: 'Smith',
},
{
id: 2,
first: 'Bob',
last: 'Davis',
},
{
id: 3,
first: 'Charlie',
last: 'Rosenburg',
}
];
let compareWithFn = (o1, o2) => {
return o1 && o2 ? o1.id === o2.id : o1 === o2;
};
let objectSelectElement = document.getElementById('objectSelectCompareWith');
objectSelectElement.compareWith = compareWithFn; // 'id';
objectOptions.forEach((option, i) => {
let selectOption = document.createElement('ion-select-option');
selectOption.value = option;
selectOption.textContent = option.first + ' ' + option.last;
selectOption.selected = (i === 0);
objectSelectElement.appendChild(selectOption)
});
setTimeout(() => {
objectSelectElement.value = {
id: 1,
first: 'Alice',
last: 'Smith',
};
}, 3000);
var pets = document.getElementById('pets'); var pets = document.getElementById('pets');
pets.value = ['bird', 'dog']; pets.value = ['bird', 'dog'];

View File

@ -152,4 +152,4 @@
</ion-app> </ion-app>
</body> </body>
</html> </html>

View File

@ -59,6 +59,56 @@
</ion-list> </ion-list>
``` ```
## Objects as Values
```html
<ion-list>
<ion-list-header>Objects as Values (compareWith)</ion-list-header>
<ion-item>
<ion-label>Users</ion-label>
<ion-select [compareWith]="compareWith">
<ion-select-option *ngFor="let user of users">{{user.first + ' ' + user.last}}</ion-select-option>
</ion-select>
</ion-item>
</ion-list>
```
```typescript
import { Component } from '@angular/core';
@Component({
selector: 'select-example',
templateUrl: 'select-example.html',
styleUrls: ['./select-example.css'],
})
export class SelectExample {
users: any[] = [
{
id: 1,
first: 'Alice',
last: 'Smith',
},
{
id: 2,
first: 'Bob',
last: 'Davis',
},
{
id: 3,
first: 'Charlie',
last: 'Rosenburg',
}
];
compareWithFn = (o1, o2) => {
return o1 && o2 ? o1.id === o2.id : o1 === o2;
};
compareWith = compareWithFn;
}
```
## Interface Options ## Interface Options
```html ```html

View File

@ -59,6 +59,56 @@
</ion-list> </ion-list>
``` ```
## Objects as Values
```html
<ion-list>
<ion-list-header>Objects as Values (compareWith)</ion-list-header>
<ion-item>
<ion-label>Users</ion-label>
<ion-select id="objectSelectCompareWith"></ion-select>
</ion-item>
</ion-list>
```
```javascript
let objectOptions = [
{
id: 1,
first: 'Alice',
last: 'Smith',
},
{
id: 2,
first: 'Bob',
last: 'Davis',
},
{
id: 3,
first: 'Charlie',
last: 'Rosenburg',
}
];
let compareWithFn = (o1, o2) => {
return o1 && o2 ? o1.id === o2.id : o1 === o2;
};
let objectSelectElement = document.getElementById('objectSelectCompareWith');
objectSelectElement.compareWith = compareWithFn;
objectOptions.forEach((option, i) => {
let selectOption = document.createElement('ion-select-option');
selectOption.value = option;
selectOption.textContent = option.first + ' ' + option.last;
selectOption.selected = (i === 0);
objectSelectElement.appendChild(selectOption)
});
}
```
## Interface Options ## Interface Options
```html ```html

View File

@ -21,6 +21,28 @@ const customActionSheetOptions = {
subHeader: 'Select your favorite color' subHeader: 'Select your favorite color'
}; };
const objectOptions = [
{
id: 1,
first: 'Alice',
last: 'Smith'
},
{
id: 2,
first: 'Bob',
last: 'Davis'
},
{
id: 3,
first: 'Charlie',
last: 'Rosenburg'
}
];
const compareWith = (o1: any, o2: any) => {
return o1 && o2 ? o1.id === o2.id : o1 === o2;
};
const Example: React.SFC<{}> = () => ( const Example: React.SFC<{}> = () => (
<> <>
## Single Selection ## Single Selection
@ -81,6 +103,20 @@ const Example: React.SFC<{}> = () => (
</IonSelect> </IonSelect>
</IonItem> </IonItem>
</IonList> </IonList>
## Objects as Values
<IonList>
<IonListHeader>Objects as Values (compareWith)</IonListHeader>
<IonItem>
<IonLabel>Users</IonLabel>
<IonSelect compareWith={compareWith}>
{objectOptions.map((object, i) => {
return <IonSelectOption key={object.id} value={object.id}>{object.first} {object.last}</IonSelectOption>
})}
</IonSelect>
</IonItem>
</IonList>
## Interface Options ## Interface Options