diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 5a3aff172e..7ddcfe294e 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -261,11 +261,36 @@ export class Datetime implements ComponentInterface { const pickerOptions = this.generatePickerOptions(); const picker = await this.pickerCtrl.create(pickerOptions); + this.isExpanded = true; picker.onDidDismiss().then(() => { this.isExpanded = false; this.setFocus(); }); + picker.addEventListener('ionPickerColChange', async (event: any) => { + const data = event.detail; + + /** + * Don't bother checking for non-dates as things like hours or minutes + * are always going to have the same number of column options + */ + if (data.name !== 'month' && data.name !== 'day' && data.name !== 'year') { return; } + + const colSelectedIndex = data.selectedIndex; + const colOptions = data.options; + + const changeData: any = {}; + changeData[data.name] = { + value: colOptions[colSelectedIndex].value + }; + + this.updateDatetimeValue(changeData); + const columns = this.generateColumns(); + + picker.columns = columns; + + await this.validate(picker); + }); await this.validate(picker); await picker.present(); } @@ -300,6 +325,7 @@ export class Datetime implements ComponentInterface { text: this.cancelText, role: 'cancel', handler: () => { + this.updateDatetimeValue(this.value); this.ionCancel.emit(); } }, diff --git a/core/src/components/picker-column/picker-column.tsx b/core/src/components/picker-column/picker-column.tsx index 34566e0a1d..7600d3d611 100644 --- a/core/src/components/picker-column/picker-column.tsx +++ b/core/src/components/picker-column/picker-column.tsx @@ -1,4 +1,4 @@ -import { Component, ComponentInterface, Element, Prop, QueueApi } from '@stencil/core'; +import { Component, ComponentInterface, Element, Event, EventEmitter, Prop, QueueApi, Watch } from '@stencil/core'; import { Gesture, GestureDetail, Mode, PickerColumn } from '../../interface'; import { hapticSelectionChanged } from '../../utils/haptic'; @@ -36,8 +36,18 @@ export class PickerColumnCmp implements ComponentInterface { @Prop({ context: 'queue' }) queue!: QueueApi; + /** + * Emitted when the selected value has changed + * @internal + */ + @Event() ionPickerColChange!: EventEmitter; + /** Picker column data */ @Prop() col!: PickerColumn; + @Watch('col') + protected colChanged() { + this.refresh(); + } componentWillLoad() { let pickerRotateFactor = 0; @@ -88,6 +98,10 @@ export class PickerColumnCmp implements ComponentInterface { } } + private emitColChange() { + this.ionPickerColChange.emit(this.col); + } + private setSelected(selectedIndex: number, duration: number) { // if there is a selected index, then figure out it's y position // if there isn't a selected index, then just use the top y position @@ -98,6 +112,8 @@ export class PickerColumnCmp implements ComponentInterface { // set what y position we're at cancelAnimationFrame(this.rafId); this.update(y, duration, true); + + this.emitColChange(); } private update(y: number, duration: number, saveY: boolean) { @@ -207,6 +223,9 @@ export class PickerColumnCmp implements ComponentInterface { if (notLockedIn) { // isn't locked in yet, keep decelerating until it is this.rafId = requestAnimationFrame(() => this.decelerate()); + } else { + this.velocity = 0; + this.emitColChange(); } } else if (this.y % this.optHeight !== 0) { @@ -277,10 +296,12 @@ export class PickerColumnCmp implements ComponentInterface { if (this.bounceFrom > 0) { // bounce back up this.update(this.minY, 100, true); + this.emitColChange(); return; } else if (this.bounceFrom < 0) { // bounce back down this.update(this.maxY, 100, true); + this.emitColChange(); return; } @@ -308,6 +329,15 @@ export class PickerColumnCmp implements ComponentInterface { } } + /** + * Only update selected value if column has a + * velocity of 0. If it does not, then the + * column is animating might land on + * a value different than the value at + * selectedIndex + */ + if (this.velocity !== 0) { return; } + const selectedIndex = clamp(min, this.col.selectedIndex || 0, max); if (this.col.prevSelected !== selectedIndex || forceRefresh) { const y = (selectedIndex * this.optHeight) * -1; diff --git a/core/src/components/picker-column/test/standalone/e2e.ts b/core/src/components/picker-column/test/standalone/e2e.ts new file mode 100644 index 0000000000..632d4adea1 --- /dev/null +++ b/core/src/components/picker-column/test/standalone/e2e.ts @@ -0,0 +1,19 @@ +import { testPickerColumn } from '../test.utils'; + +const TEST_TYPE = 'standalone'; + +test('picker-column: standalone', async () => { + await testPickerColumn(TEST_TYPE, '#single-column-button'); +}); + +test('picker-column:multi-column standalone', async () => { + await testPickerColumn(TEST_TYPE, '#multiple-column-button'); +}); + +test('picker-column:rtl: standalone', async () => { + await testPickerColumn(TEST_TYPE, '#single-column-button', true); +}); + +test('picker-column:multi-column:rtl standalone', async () => { + await testPickerColumn(TEST_TYPE, '#multiple-column-button', true); +}); diff --git a/core/src/components/picker-column/test/standalone/index.html b/core/src/components/picker-column/test/standalone/index.html new file mode 100644 index 0000000000..188f10ac5b --- /dev/null +++ b/core/src/components/picker-column/test/standalone/index.html @@ -0,0 +1,92 @@ + + + + + + Picker Column - Standalone + + + + + + + + + + Open Single Column Picker + Open Multi Column Picker + + + diff --git a/core/src/components/picker-column/test/test.utils.ts b/core/src/components/picker-column/test/test.utils.ts new file mode 100644 index 0000000000..6a2b36ae64 --- /dev/null +++ b/core/src/components/picker-column/test/test.utils.ts @@ -0,0 +1,60 @@ +import { newE2EPage } from '@stencil/core/testing'; + +import { cleanScreenshotName, dragElementBy, generateE2EUrl, listenForEvent, waitForFunctionTestContext } from '../../../utils/test/utils'; + +export async function testPickerColumn( + type: string, + selector: string, + rtl = false, + screenshotName: string = cleanScreenshotName(selector) +) { + try { + const pageUrl = generateE2EUrl('picker-column', type, rtl); + if (rtl) { + screenshotName = `${screenshotName} rtl`; + } + + const page = await newE2EPage({ + url: pageUrl + }); + + const screenshotCompares = []; + + const openButton = await page.find(selector); + await openButton.click(); + await page.waitFor(250); + + screenshotCompares.push(await page.compareScreenshot(`${screenshotName}`)); + + // Setup counter + let colChangeCounter: any; + + // Expose an event callback method + const COL_CHANGE = 'onIonPickerColChange'; + await page.exposeFunction(COL_CHANGE, () => { + colChangeCounter.count += 1; + }); + + const columns = await page.$$('ion-picker-column'); + for (const column of Array.from(columns)) { + colChangeCounter = { count: 0 }; + + // Attach a listener to element with a callback + await listenForEvent(page, 'ionPickerColChange', column, COL_CHANGE); + + // Simulate a column drag + await dragElementBy(column, page, 0, 100); + + // Wait for ionPickerColChange event to be emitted once + await waitForFunctionTestContext((payload: any) => { + return payload.colChangeCounter.count === 1; + }, { colChangeCounter }); + } + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } + } catch (err) { + throw err; + } +} diff --git a/core/src/utils/test/utils.ts b/core/src/utils/test/utils.ts index 073b918c3e..cca317df34 100644 --- a/core/src/utils/test/utils.ts +++ b/core/src/utils/test/utils.ts @@ -13,3 +13,77 @@ export function cleanScreenshotName(screenshotName: string): string { .replace(/[^0-9a-zA-Z\s]/gi, '') .toLowerCase(); } + +/** + * Listens for an event and fires a callback + * @param page - The Puppeteer `page` object + * @param eventType: string - The event name to listen for. ex: `ionPickerColChange` + * @param element: HTMLElement - An HTML element + * @param callbackName: string - The name of the callback function to + * call when the event is fired. + * + * Note: The callback function must be added using + * page.exposeFunction prior to calling this function. + */ +export const listenForEvent = async (page: any, eventType: string, element: any, callbackName: string): Promise => { + try { + return await page.evaluate((scopeEventType: string, scopeElement: any, scopeCallbackName: string) => { + scopeElement.addEventListener(scopeEventType, (e: any) => { + (window as any)[scopeCallbackName](e); + }); + }, eventType, element, callbackName); + } catch (err) { + throw err; + } +}; + +/** + * Drags an element by (x, y) pixels + * @param element: HTMLElement - The HTML Element to drag + * @param page - The Puppeteer 'page' object + * @param x: number - Amount to drag `element` by on the x-axis + * @param y: number - Amount to drag `element` by on the y-axis + */ +export const dragElementBy = async (element: any, page: any, x = 0, y = 0): Promise => { + try { + const boundingBox = await element.boundingBox(); + + const startX = boundingBox.x + boundingBox.width / 2; + const startY = boundingBox.y + boundingBox.height / 2; + + const endX = startX + x; + const endY = startY + y; + + const midX = endX / 2; + const midY = endY / 2; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(midX, midY); + await page.mouse.move(endX, endY); + await page.mouse.up(); + + } catch (err) { + throw err; + } +}; + +/** + * Wait for a function to return true + * This method runs in the context of the + * test whereas page.waitForFunction runs in + * the context of the browser + * @param fn - The function to run + * @param params: any - Any parameters that the fn needs + * @param interval: number - Interval to run setInterval on + */ +export const waitForFunctionTestContext = async (fn: any, params: any, interval = 16): Promise => { + return new Promise(resolve => { + const intervalId = setInterval(() => { + if (fn(params)) { + clearInterval(intervalId); + return resolve(); + } + }, interval); + }); +};