diff --git a/core/src/components/picker-column-internal/picker-column-internal.tsx b/core/src/components/picker-column-internal/picker-column-internal.tsx index 465aff86a4..5a1186bea4 100644 --- a/core/src/components/picker-column-internal/picker-column-internal.tsx +++ b/core/src/components/picker-column-internal/picker-column-internal.tsx @@ -36,6 +36,70 @@ export class PickerColumnInternal implements ComponentInterface { * A list of options to be displayed in the picker */ @Prop() items: PickerColumnItem[] = []; + @Watch('items') + itemsChange(currentItems: PickerColumnItem[], previousItems: PickerColumnItem[]) { + const { value } = this; + + /** + * When the items change, it is possible for the item + * that was selected to no longer exist. In that case, we need + * to automatically select the nearest item. If we do not, + * then the scroll position will be reset to zero and it will + * look like the first item was automatically selected. + * + * If we cannot find a closest item then we do nothing, and + * the browser will reset the scroll position to 0. + */ + const findCurrentItem = currentItems.find((item) => item.value === value); + if (!findCurrentItem) { + /** + * The default behavior is to assume + * that the new set of data is similar to the old + * set of data, just with some items filtered out. + * We walk backwards through the data to find the + * closest enabled picker item and select it. + * + * Developers can also swap the items out for an entirely + * new set of data. In that case, the value we select + * here likely will not make much sense. For this use case, + * developers should update the `value` prop themselves + * when swapping out the data. + */ + const findPreviousItemIndex = previousItems.findIndex((item) => item.value === value); + if (findPreviousItemIndex === -1) { + return; + } + + /** + * Step through the current items backwards + * until we find a neighbor we can select. + * We start at the last known location of the + * current selected item in order to + * account for data that has been added. This + * search prioritizes stability in that it + * tries to keep the scroll position as close + * to where it was before the update. + * Before Items: ['a', 'b', 'c'], Selected Value: 'b' + * After Items: ['a', 'dog', 'c'] + * Even though 'dog' is a different item than 'b', + * it is the closest item we can select while + * preserving the scroll position. + */ + let nearestItem; + for (let i = findPreviousItemIndex; i >= 0; i--) { + const item = currentItems[i]; + if (item !== undefined && item.disabled !== true) { + nearestItem = item; + break; + } + } + + if (nearestItem) { + this.setValue(nearestItem.value); + return; + } + } + } /** * The selected option in the picker. diff --git a/core/src/components/picker-column-internal/test/update-items/picker-column-internal.e2e.ts b/core/src/components/picker-column-internal/test/update-items/picker-column-internal.e2e.ts new file mode 100644 index 0000000000..4ca337ab38 --- /dev/null +++ b/core/src/components/picker-column-internal/test/update-items/picker-column-internal.e2e.ts @@ -0,0 +1,175 @@ +import { expect } from '@playwright/test'; +import { test } from '@utils/test/playwright'; + +test.describe('picker-column-internal: updating items', () => { + test('should select nearest neighbor when updating items', async ({ page }) => { + await page.setContent(` + + + + + + `); + + const pickerColumn = page.locator('ion-picker-column-internal'); + await expect(pickerColumn).toHaveJSProperty('value', 5); + + await pickerColumn.evaluate((el: HTMLIonPickerColumnInternalElement) => { + el.items = [ + { text: '1', value: 1 }, + { text: '2', value: 2 }, + ]; + }); + + await page.waitForChanges(); + await expect(pickerColumn).toHaveJSProperty('value', 2); + }); + test('should select same position item even if item value is different', async ({ page }) => { + await page.setContent(` + + + + + + `); + + const pickerColumn = page.locator('ion-picker-column-internal'); + await expect(pickerColumn).toHaveJSProperty('value', 5); + + await pickerColumn.evaluate((el: HTMLIonPickerColumnInternalElement) => { + el.items = [ + { text: '1', value: 1 }, + { text: '2', value: 2 }, + { text: '3', value: 3 }, + { text: '4', value: 4 }, + { text: '1000', value: 1000 }, + ]; + }); + + await page.waitForChanges(); + await expect(pickerColumn).toHaveJSProperty('value', 1000); + }); + test('should not select a disabled item', async ({ page }) => { + await page.setContent(` + + + + + + `); + + const pickerColumn = page.locator('ion-picker-column-internal'); + await expect(pickerColumn).toHaveJSProperty('value', 5); + + await pickerColumn.evaluate((el: HTMLIonPickerColumnInternalElement) => { + el.items = [ + { text: '1', value: 1 }, + { text: '2', value: 2 }, + { text: '3', value: 3, disabled: true }, + ]; + }); + + await page.waitForChanges(); + await expect(pickerColumn).toHaveJSProperty('value', 2); + }); + test('should reset to the first item if no good item was found', async ({ page }) => { + await page.setContent(` + + + + + + `); + + const pickerColumn = page.locator('ion-picker-column-internal'); + await expect(pickerColumn).toHaveJSProperty('value', 5); + + await pickerColumn.evaluate((el: HTMLIonPickerColumnInternalElement) => { + el.items = [ + { text: '1', value: 1 }, + { text: '2', value: 2, disabled: true }, + { text: '3', value: 3, disabled: true }, + ]; + }); + + await page.waitForChanges(); + await expect(pickerColumn).toHaveJSProperty('value', 1); + }); + test('should still select correct value if data was added', async ({ page }) => { + await page.setContent(` + + + + + + `); + + const pickerColumn = page.locator('ion-picker-column-internal'); + await expect(pickerColumn).toHaveJSProperty('value', 5); + + await pickerColumn.evaluate((el: HTMLIonPickerColumnInternalElement) => { + el.items = [ + { text: '1', value: 1 }, + { text: '2', value: 2 }, + { text: '3', value: 3 }, + { text: '4', value: 4 }, + { text: '6', value: 6 }, + { text: '7', value: 7 }, + { text: '5', value: 5 }, + ]; + }); + + await page.waitForChanges(); + await expect(pickerColumn).toHaveJSProperty('value', 5); + }); +});