From d5068f8430eb9f01045c3665abd7aa4bea85135a Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Thu, 21 Apr 2016 23:46:00 -0500 Subject: [PATCH 1/3] feat(picker): init picker --- ionic/components.ios.scss | 1 + ionic/components.md.scss | 1 + ionic/components.ts | 102 ++-- ionic/components.wp.scss | 1 + ionic/components/picker/picker.ios.scss | 79 ++++ ionic/components/picker/picker.md.scss | 5 + ionic/components/picker/picker.scss | 72 +++ ionic/components/picker/picker.ts | 464 +++++++++++++++++++ ionic/components/picker/picker.wp.scss | 5 + ionic/components/picker/test/basic/index.ts | 77 +++ ionic/components/picker/test/basic/main.html | 9 + ionic/config/modes.ts | 9 + 12 files changed, 774 insertions(+), 51 deletions(-) create mode 100644 ionic/components/picker/picker.ios.scss create mode 100644 ionic/components/picker/picker.md.scss create mode 100644 ionic/components/picker/picker.scss create mode 100644 ionic/components/picker/picker.ts create mode 100644 ionic/components/picker/picker.wp.scss create mode 100644 ionic/components/picker/test/basic/index.ts create mode 100644 ionic/components/picker/test/basic/main.html diff --git a/ionic/components.ios.scss b/ionic/components.ios.scss index aa02af24f1..38f765f97d 100644 --- a/ionic/components.ios.scss +++ b/ionic/components.ios.scss @@ -21,6 +21,7 @@ "components/loading/loading.ios", "components/menu/menu.ios", "components/modal/modal.ios", + "components/picker/picker.ios", "components/radio/radio.ios", "components/searchbar/searchbar.ios", "components/segment/segment.ios", diff --git a/ionic/components.md.scss b/ionic/components.md.scss index 91911f4c62..af46292e28 100644 --- a/ionic/components.md.scss +++ b/ionic/components.md.scss @@ -21,6 +21,7 @@ "components/loading/loading.md", "components/menu/menu.md", "components/modal/modal.md", + "components/picker/picker.md", "components/radio/radio.md", "components/searchbar/searchbar.md", "components/segment/segment.md", diff --git a/ionic/components.ts b/ionic/components.ts index 86455e54bb..03a35dd926 100644 --- a/ionic/components.ts +++ b/ionic/components.ts @@ -1,51 +1,51 @@ -export * from './components/app/app' -export * from './components/app/id' - -export * from './components/action-sheet/action-sheet' -export * from './components/alert/alert' -export * from './components/badge/badge' -export * from './components/blur/blur' -export * from './components/button/button' -export * from './components/checkbox/checkbox' -export * from './components/content/content' -export * from './components/icon/icon' -export * from './components/img/img' -export * from './components/infinite-scroll/infinite-scroll' -export * from './components/infinite-scroll/infinite-scroll-content' -export * from './components/input/input' -export * from './components/item/item' -export * from './components/item/item-sliding' -export * from './components/label/label' -export * from './components/list/list' -export * from './components/loading/loading' -export * from './components/menu/menu-controller' -export * from './components/menu/menu' -export * from './components/menu/menu-types' -export * from './components/menu/menu-toggle' -export * from './components/menu/menu-close' -export * from './components/modal/modal' -export * from './components/nav/nav' -export * from './components/nav/nav-controller' -export * from './components/nav/view-controller' -export * from './components/nav/nav-params' -export * from './components/nav/nav-push' -export * from './components/nav/nav-router' -export * from './components/navbar/navbar' -export * from './components/option/option' -export * from './components/radio/radio-button' -export * from './components/radio/radio-group' -export * from './components/refresher/refresher' -export * from './components/refresher/refresher-content' -export * from './components/scroll/scroll' -export * from './components/searchbar/searchbar' -export * from './components/segment/segment' -export * from './components/select/select' -export * from './components/show-hide-when/show-hide-when' -export * from './components/slides/slides' -export * from './components/spinner/spinner' -export * from './components/tabs/tabs' -export * from './components/tabs/tab' -export * from './components/tap-click/tap-click' -export * from './components/toggle/toggle' -export * from './components/toolbar/toolbar' -export * from './components/virtual-scroll/virtual-scroll' +export * from './components/app/app'; +export * from './components/app/id'; +export * from './components/action-sheet/action-sheet'; +export * from './components/alert/alert'; +export * from './components/badge/badge'; +export * from './components/blur/blur'; +export * from './components/button/button'; +export * from './components/checkbox/checkbox'; +export * from './components/content/content'; +export * from './components/icon/icon'; +export * from './components/img/img'; +export * from './components/infinite-scroll/infinite-scroll'; +export * from './components/infinite-scroll/infinite-scroll-content'; +export * from './components/input/input'; +export * from './components/item/item'; +export * from './components/item/item-sliding'; +export * from './components/label/label'; +export * from './components/list/list'; +export * from './components/loading/loading'; +export * from './components/menu/menu-controller'; +export * from './components/menu/menu'; +export * from './components/menu/menu-types'; +export * from './components/menu/menu-toggle'; +export * from './components/menu/menu-close'; +export * from './components/modal/modal'; +export * from './components/nav/nav'; +export * from './components/nav/nav-controller'; +export * from './components/nav/view-controller'; +export * from './components/nav/nav-params'; +export * from './components/nav/nav-push'; +export * from './components/nav/nav-router'; +export * from './components/navbar/navbar'; +export * from './components/option/option'; +export * from './components/picker/picker'; +export * from './components/radio/radio-button'; +export * from './components/radio/radio-group'; +export * from './components/refresher/refresher'; +export * from './components/refresher/refresher-content'; +export * from './components/scroll/scroll'; +export * from './components/searchbar/searchbar'; +export * from './components/segment/segment'; +export * from './components/select/select'; +export * from './components/show-hide-when/show-hide-when'; +export * from './components/slides/slides'; +export * from './components/spinner/spinner'; +export * from './components/tabs/tabs'; +export * from './components/tabs/tab'; +export * from './components/tap-click/tap-click'; +export * from './components/toggle/toggle'; +export * from './components/toolbar/toolbar'; +export * from './components/virtual-scroll/virtual-scroll'; diff --git a/ionic/components.wp.scss b/ionic/components.wp.scss index 5cdf5f81d1..a799c828ec 100644 --- a/ionic/components.wp.scss +++ b/ionic/components.wp.scss @@ -21,6 +21,7 @@ "components/loading/loading.wp", "components/menu/menu.wp", "components/modal/modal.wp", + "components/picker/picker.wp", "components/radio/radio.wp", "components/searchbar/searchbar.wp", "components/segment/segment.wp", diff --git a/ionic/components/picker/picker.ios.scss b/ionic/components/picker/picker.ios.scss new file mode 100644 index 0000000000..b8b68cef65 --- /dev/null +++ b/ionic/components/picker/picker.ios.scss @@ -0,0 +1,79 @@ +@import "../../globals.ios"; +@import "./picker"; + +// iOS Picker +// -------------------------------------------------- + +$picker-ios-height: 260px !default; +$picker-ios-background-color: #cfd5da !default; + +$picker-ios-toolbar-height: 44px !default; +$picker-ios-toolbar-background-color: #f7f7f8 !default; + +$picker-ios-button-height: $picker-ios-toolbar-height !default; +$picker-ios-button-text-color: color($colors-ios, primary) !default; +$picker-ios-button-background-color: transparent !default; + +$picker-ios-option-offset-y: 90px !default; +$picker-ios-option-font-size: 18px !default; +$picker-ios-option-line-height: 24px !default; + + +.picker-wrapper { + height: $picker-ios-height; + + border-top: 1px solid #929499; + + background: $picker-ios-background-color; +} + +.hairlines .picker-wrapper { + border-width: $hairlines-width; +} + +.picker-toolbar { + display: flex; + + height: $picker-ios-toolbar-height; + background: $picker-ios-toolbar-background-color; +} + +.picker-toolbar-button { + flex: 1; + text-align: right; +} + +.picker-toolbar-cancel { + text-align: left; +} + +.picker-button, +.picker-button.activated { + margin: 0; + + height: $picker-ios-button-height; + + color: $picker-ios-button-text-color; + background: $picker-ios-button-background-color; +} + +.picker-offset { + transform: translateY($picker-ios-option-offset-y); +} + +.picker-column { + padding: 0 10px; +} + +.picker-prefix, +.picker-suffix, +.picker-options { + padding: 0 8px; + font-size: $picker-ios-option-font-size; + line-height: $picker-ios-option-line-height; +} + +.picker-prefix, +.picker-suffix { + padding: 0 +} diff --git a/ionic/components/picker/picker.md.scss b/ionic/components/picker/picker.md.scss new file mode 100644 index 0000000000..6e999c22ce --- /dev/null +++ b/ionic/components/picker/picker.md.scss @@ -0,0 +1,5 @@ +@import "../../globals.md"; +@import "./picker"; + +// Material Design Picker +// -------------------------------------------------- diff --git a/ionic/components/picker/picker.scss b/ionic/components/picker/picker.scss new file mode 100644 index 0000000000..910b3af9b1 --- /dev/null +++ b/ionic/components/picker/picker.scss @@ -0,0 +1,72 @@ +@import "../../globals.core"; + +// Picker +// -------------------------------------------------- + +$picker-width: 100% !default; +$picker-max-width: 500px !default; + + +ion-picker-cmp { + position: absolute; + top: 0; + left: 0; + z-index: $z-index-overlay; + display: block; + + width: $picker-width; + height: $picker-width; +} + +.picker-toolbar { + z-index: 1; +} + +.picker-wrapper { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: $z-index-overlay-wrapper; + display: flex; + flex-direction: column; + overflow: hidden; + + margin: auto; + + width: $picker-width; + max-width: $picker-max-width; + + transform: translate3d(0, 100%, 0); +} + +.picker-columns { + display: flex; + flex: 1; + overflow: hidden; +} + +.picker-offset { + display: flex; +} + +.picker-column { + flex: 1; +} + +.picker-prefix { + flex: 1; + text-align: right; +} + +.picker-suffix { + flex: 1; + text-align: left; +} + +.picker-option { + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/ionic/components/picker/picker.ts b/ionic/components/picker/picker.ts new file mode 100644 index 0000000000..92f1350e38 --- /dev/null +++ b/ionic/components/picker/picker.ts @@ -0,0 +1,464 @@ +import {Component, ElementRef, Input, ViewChild, Renderer, HostListener, ChangeDetectionStrategy, ViewEncapsulation} from 'angular2/core'; +import {NgClass, NgIf, NgFor} from 'angular2/common'; + +import {Animation} from '../../animations/animation'; +import {Transition, TransitionOptions} from '../../transitions/transition'; +import {Config} from '../../config/config'; +import {isPresent} from '../../util/util'; +import {NavParams} from '../nav/nav-params'; +import {ViewController} from '../nav/view-controller'; +import {raf, CSS, pointerCoord} from '../../util/dom'; + + + +/** + * @name Picker + * @description + * + * @usage + * ```ts + * constructor(private nav: NavController) {} + * + * presentSelector() { + * let picker = Picker.create({ + * + * }); + * this.nav.present(picker); + * } + * + * ``` + * + */ +export class Picker extends ViewController { + + constructor(opts: PickerOptions = {}) { + opts.columns = opts.columns || []; + opts.enableBackdropDismiss = isPresent(opts.enableBackdropDismiss) ? !!opts.enableBackdropDismiss : true; + + super(PickerDisplayCmp, opts); + this.viewType = 'picker'; + this.isOverlay = true; + + // by default, pickers should not fire lifecycle events of other views + // for example, when an picker enters, the current active view should + // not fire its lifecycle events because it's not conceptually leaving + this.fireOtherLifecycles = false; + this.usePortal = true; + } + + /** + * @private + */ + getTransitionName(direction: string) { + let key = (direction === 'back' ? 'pickerLeave' : 'pickerEnter'); + return this._nav && this._nav.config.get(key); + } + + /** + * @param {string} cssClass CSS class name to add to the picker's outer wrapper. + */ + setCssClass(cssClass: string) { + this.data.cssClass = cssClass; + } + + static create(opts: PickerOptions = {}) { + return new Picker(opts); + } + +} + + + +/** + * @private + */ +@Component({ + selector: '.picker-column', + template: + '
' + + '
{{col.prefix}}
' + + '
' + + '
' + + '{{o.text}}' + + '
' + + '
' + + '
{{col.suffix}}
' + + '
', + host: { + '[style.flex]': 'col.flex', + '(touchstart)': 'pointerStart($event)', + '(touchmove)': 'pointerMove($event)', + '(touchend)': 'pointerEnd($event)', + '(mousedown)': 'pointerStart($event)', + '(mousemove)': 'pointerMove($event)', + '(mouseup)': 'pointerEnd($event)', + } +}) +class PickerColumnCmp { + @ViewChild('colEle') colEle: ElementRef; + @Input() col: PickerColumn; + y: number; + colHeight: number; + optHeight: number; + velocity: number; + pos: number[] = []; + scrollingDown: boolean; + msPrv: number = 0; + startY: number = null; + + ngAfterViewInit() { + let colEle: HTMLElement = this.colEle.nativeElement; + + this.colHeight = colEle.clientHeight; + this.optHeight = (colEle.firstElementChild ? colEle.firstElementChild.clientHeight : 0); + + this.setY(0, true); + } + + pointerStart(ev) { + if (this.isPrevented(ev)) { + return; + } + + this.startY = pointerCoord(ev).y; + + this.velocity = 0; + this.pos.length = 0; + this.pos.push(this.startY, Date.now()); + + console.debug('picker, pointerStart', ev.type, this.startY); + } + + pointerMove(ev) { + if (this.startY !== null) { + if (this.isPrevented(ev)) { + return; + } + + let currentY = pointerCoord(ev).y; + console.debug('picker, pointerMove', ev.type, currentY); + + this.pos.push(currentY, Date.now()); + this.setY(this.startY + currentY, false); + } + } + + pointerEnd(ev) { + if (this.startY !== null) { + + if (this.isPrevented(ev)) { + return; + } + + var endY = pointerCoord(ev).y; + + console.debug('picker, pointerEnd', ev.type, endY); + + this.pos.push(endY, Date.now()); + this.velocity = 0; + this.scrollingDown = (endY < this.startY); + + var endPos = (this.pos.length - 1); + var startPos = endPos; + var timeRange = (Date.now() - 100); + + // move pointer to position measured 100ms ago + for (var i = endPos; i > 0 && this.pos[i] > timeRange; i -= 2) { + startPos = i; + } + + if (startPos !== endPos) { + // compute relative movement between these two points + var timeOffset = (this.pos[endPos] - this.pos[startPos]); + var movedTop = (this.pos[startPos - 1] - this.pos[endPos - 1]); + + // based on XXms compute the movement to apply for each render step + this.velocity = ((movedTop / timeOffset) * FRAME_MS); + } + + this.setY(this.startY + endY, true); + + this.decelerate(); + + this.startY = null; + } + } + + decelerate() { + var self = this; + + if (self.velocity) { + self.velocity *= DECELERATION_FRICTION; + console.log(`decelerate velocity ${self.velocity}`); + + var y = self.y + self.velocity; + self.setY(y, true); + + raf(self.decelerate.bind(self)); + + } else if (self.y % this.optHeight !== 0) { + + self.y = self.y + (this.scrollingDown ? -1 : 1); + + console.log(`lock in ${self.y}`); + + self.setY(self.y, true); + + raf(self.decelerate.bind(self)); + } + } + + setY(yOffset: number, saveY: boolean) { + let y = yOffset + this.y; + + console.log(`y: ${y}, yOffset: ${yOffset}, colHeight: ${this.colHeight}, optHeight: ${this.optHeight}`); + + let colEleStyle = this.colEle.nativeElement.style; + colEleStyle[CSS.transform] = `translate3d(0px,${y}px,0px)`; + + if (saveY) { + this.y = y; + } + } + + isPrevented(ev) { + if (ev.type.indexOf('touch') > -1) { + this.msPrv = Date.now() + 2000; + + } else if (this.msPrv > Date.now() && ev.type.indexOf('mouse') > -1) { + ev.preventDefault(); + ev.stopPropagation(); + return true; + } + } + +} + + +/** + * @private + */ +@Component({ + selector: 'ion-picker-cmp', + template: + '' + + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
', + host: { + 'role': 'dialog' + }, + directives: [NgClass, NgIf, NgFor, PickerColumnCmp], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +class PickerDisplayCmp { + private d: PickerOptions; + private created: number; + private lastClick: number; + private id: number; + + constructor( + private _viewCtrl: ViewController, + private _elementRef: ElementRef, + private _config: Config, + params: NavParams, + renderer: Renderer + ) { + this.d = params.data; + + if (this.d.cssClass) { + this.d.cssClass.split(' ').forEach(cssClass => { + renderer.setElementClass(_elementRef.nativeElement, cssClass, true); + }); + } + + this.id = (++pickerIds); + this.created = Date.now(); + this.lastClick = 0; + } + + onPageLoaded() { + // normalize the data + let data = this.d; + + data.buttons = data.buttons.map(button => { + if (typeof button === 'string') { + return { text: button }; + } + if (button.role) { + button.cssRole = `picker-toolbar-${button.role}`; + } + return button; + }); + + data.columns = data.columns.map(column => { + if (!column.flex) { + column.flex = 1; + } + return column; + }); + } + + @HostListener('body:keyup', ['$event']) + private _keyUp(ev: KeyboardEvent) { + if (this.isEnabled() && this._viewCtrl.isLast()) { + if (ev.keyCode === 13) { + if (this.lastClick + 1000 < Date.now()) { + // do not fire this click if there recently was already a click + // this can happen when the button has focus and used the enter + // key to click the button. However, both the click handler and + // this keyup event will fire, so only allow one of them to go. + console.debug('picker, enter button'); + let button = this.d.buttons[this.d.buttons.length - 1]; + this.btnClick(button); + } + + } else if (ev.keyCode === 27) { + console.debug('picker, escape button'); + this.bdClick(); + } + } + } + + onPageDidEnter() { + let activeElement: any = document.activeElement; + if (activeElement) { + activeElement.blur(); + } + + let focusableEle = this._elementRef.nativeElement.querySelector('button'); + if (focusableEle) { + focusableEle.focus(); + } + } + + btnClick(button, dismissDelay?) { + if (!this.isEnabled()) { + return; + } + + // keep the time of the most recent button click + this.lastClick = Date.now(); + + let shouldDismiss = true; + + if (button.handler) { + // a handler has been provided, execute it + // pass the handler the values from the inputs + if (button.handler(this.getValues()) === false) { + // if the return value of the handler is false then do not dismiss + shouldDismiss = false; + } + } + + if (shouldDismiss) { + setTimeout(() => { + this.dismiss(button.role); + }, dismissDelay || this._config.get('pageTransitionDelay')); + } + } + + bdClick() { + if (this.isEnabled() && this.d.enableBackdropDismiss) { + this.dismiss('backdrop'); + } + } + + dismiss(role): Promise { + return this._viewCtrl.dismiss(this.getValues(), role); + } + + getValues() { + // this is an alert with text inputs + // return an object of all the values with the input name as the key + let values = {}; + this.d.columns.forEach(col => { + values[col.name] = col.value; + }); + return values; + } + + isEnabled() { + let tm = this._config.getNumber('overlayCreatedDiff', 750); + return (this.created + tm < Date.now()); + } +} + +export interface PickerOptions { + buttons?: any[]; + columns?: PickerColumn[]; + cssClass?: string; + enableBackdropDismiss?: boolean; +} + +export interface PickerColumn { + name?: string; + value?: string; + prefix?: string; + suffix?: string; + options: PickerColumnOption[]; + flex?: number; + cssClass?: string; +} + +export interface PickerColumnOption { + value?: string; + text?: string; + checked?: boolean; + id?: string; +} + + +/** + * Animations for pickers + */ +class PickerSlideIn extends Transition { + constructor(enteringView: ViewController, leavingView: ViewController, opts: TransitionOptions) { + super(opts); + + let ele = enteringView.pageRef().nativeElement; + let backdrop = new Animation(ele.querySelector('.backdrop')); + let wrapper = new Animation(ele.querySelector('.picker-wrapper')); + + backdrop.fromTo('opacity', 0.01, 0.26); + wrapper.fromTo('translateY', '100%', '0%'); + + this.easing('cubic-bezier(.36,.66,.04,1)').duration(400).add(backdrop).add(wrapper); + } +} +Transition.register('picker-slide-in', PickerSlideIn); + + +class PickerSlideOut extends Transition { + constructor(enteringView: ViewController, leavingView: ViewController, opts: TransitionOptions) { + super(opts); + + let ele = leavingView.pageRef().nativeElement; + let backdrop = new Animation(ele.querySelector('.backdrop')); + let wrapper = new Animation(ele.querySelector('.picker-wrapper')); + + backdrop.fromTo('opacity', 0.26, 0); + wrapper.fromTo('translateY', '0%', '100%'); + + this.easing('cubic-bezier(.36,.66,.04,1)').duration(450).add(backdrop).add(wrapper); + } +} +Transition.register('picker-slide-out', PickerSlideOut); + + +let pickerIds = -1; +const MIN_VELOCITY_START_DECELERATION = 4; +const MIN_VELOCITY_CONTINUE_DECELERATION = 0.12; +const DECELERATION_FRICTION = 0.97; +const FRAME_MS = (1000 / 60); diff --git a/ionic/components/picker/picker.wp.scss b/ionic/components/picker/picker.wp.scss new file mode 100644 index 0000000000..466cd79ef2 --- /dev/null +++ b/ionic/components/picker/picker.wp.scss @@ -0,0 +1,5 @@ +@import "../../globals.wp"; +@import "./picker"; + +// Windows Picker +// -------------------------------------------------- diff --git a/ionic/components/picker/test/basic/index.ts b/ionic/components/picker/test/basic/index.ts new file mode 100644 index 0000000000..ac9e51ac1f --- /dev/null +++ b/ionic/components/picker/test/basic/index.ts @@ -0,0 +1,77 @@ +import {App, Page, Picker, NavController} from 'ionic-angular'; + + +@Page({ + templateUrl: 'main.html' +}) +class E2EPage { + + constructor(private nav: NavController) { + setTimeout(() => { + this.presentPicker() + }, 250); + } + + presentPicker() { + let picker = Picker.create({ + buttons: [ + { + text: 'Cancel', + role: 'cancel' + }, + 'Done' + ], + columns: [ + { + prefix: 'prefix', + suffix: 'suffix', + options: [ + { text: 'Jan' }, + { text: 'Feb' }, + { text: 'Mar' }, + { text: 'Apr' }, + { text: 'May' }, + { text: 'Jun' }, + { text: 'Jul' }, + { text: 'Aug' }, + { text: 'Sep' }, + { text: 'Oct' }, + { text: 'Nov' }, + { text: 'Dec' }, + ] + }, + // { + // prefix: 'prefix', + // suffix: 'suffix', + // options: [ + // { text: 'Jan' }, + // { text: 'Feb' }, + // { text: 'Mar' }, + // { text: 'Apr' }, + // { text: 'May' }, + // { text: 'Jun' }, + // { text: 'Jul' }, + // { text: 'Aug' }, + // { text: 'Sep' }, + // { text: 'Oct' }, + // { text: 'Nov' }, + // { text: 'Dec' }, + // ] + // }, + ] + }); + + this.nav.present(picker); + } +} + + +@App({ + template: '' +}) +class E2EApp { + root; + constructor() { + this.root = E2EPage; + } +} diff --git a/ionic/components/picker/test/basic/main.html b/ionic/components/picker/test/basic/main.html new file mode 100644 index 0000000000..90bd279550 --- /dev/null +++ b/ionic/components/picker/test/basic/main.html @@ -0,0 +1,9 @@ + + Picker + + + + + + + diff --git a/ionic/config/modes.ts b/ionic/config/modes.ts index 590952479a..dae7588cd1 100644 --- a/ionic/config/modes.ts +++ b/ionic/config/modes.ts @@ -28,6 +28,9 @@ Config.setModeConfig('ios', { pageTransition: 'ios-transition', pageTransitionDelay: 16, + pickerEnter: 'picker-slide-in', + pickerLeave: 'picker-slide-out', + spinner: 'ios', tabbarPlacement: 'bottom', @@ -60,6 +63,9 @@ Config.setModeConfig('md', { pageTransition: 'md-transition', pageTransitionDelay: 96, + pickerEnter: 'picker-slide-in', + pickerLeave: 'picker-slide-out', + spinner: 'crescent', tabbarHighlight: true, @@ -95,6 +101,9 @@ Config.setModeConfig('wp', { pageTransition: 'wp-transition', pageTransitionDelay: 96, + pickerEnter: 'picker-slide-in', + pickerLeave: 'picker-slide-out', + spinner: 'circles', tabbarPlacement: 'top', From 066ab712c018d9e49005417481ccd2597a349d1d Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Tue, 26 Apr 2016 15:33:31 -0500 Subject: [PATCH 2/3] chore(nativeRaf): use native raf over zone wrapped --- ionic/components/content/content.ts | 6 +++--- ionic/components/img/img.ts | 4 ++-- ionic/components/input/native-input.ts | 2 +- ionic/components/item/item-sliding-gesture.ts | 6 +++--- ionic/components/nav/nav-controller.ts | 1 - ionic/components/tap-click/activator.ts | 2 +- ionic/components/tap-click/ripple.ts | 4 ++-- ionic/components/tap-click/tap-click.ts | 2 +- ionic/components/virtual-scroll/virtual-scroll.ts | 6 +++--- ionic/util/dom.ts | 9 ++++++--- ionic/util/keyboard.ts | 6 +++--- ionic/util/scroll-view.ts | 12 ++++++------ 12 files changed, 31 insertions(+), 29 deletions(-) diff --git a/ionic/components/content/content.ts b/ionic/components/content/content.ts index cda57396d7..4109b547c4 100644 --- a/ionic/components/content/content.ts +++ b/ionic/components/content/content.ts @@ -4,7 +4,7 @@ import {Ion} from '../ion'; import {IonicApp} from '../app/app'; import {Config} from '../../config/config'; import {Keyboard} from '../../util/keyboard'; -import {raf, nativeTimeout, transitionEnd} from '../../util/dom'; +import {nativeRaf, nativeTimeout, transitionEnd} from '../../util/dom'; import {ViewController} from '../nav/view-controller'; import {ScrollView} from '../../util/scroll-view'; @@ -198,8 +198,8 @@ export class Content extends Ion { lastScrollTop = currentScrollTop; - raf(() => { - raf(next); + nativeRaf(() => { + nativeRaf(next); }); } diff --git a/ionic/components/img/img.ts b/ionic/components/img/img.ts index 45a6ca04d8..421cb9c339 100644 --- a/ionic/components/img/img.ts +++ b/ionic/components/img/img.ts @@ -1,6 +1,6 @@ import {Component, Input, ElementRef, ChangeDetectionStrategy, ViewEncapsulation, NgZone} from 'angular2/core'; -import {raf} from '../../util/dom'; +import {nativeRaf} from '../../util/dom'; import {isPresent} from '../../util/util'; import {Platform} from '../../platform/platform'; @@ -62,7 +62,7 @@ export class Img { img.addEventListener('load', () => { if (img.src === this._normalizeSrc) { this._elementRef.nativeElement.appendChild(img); - raf(() => { + nativeRaf(() => { this._update(); }); } diff --git a/ionic/components/input/native-input.ts b/ionic/components/input/native-input.ts index 894a2ad9fe..381322a584 100644 --- a/ionic/components/input/native-input.ts +++ b/ionic/components/input/native-input.ts @@ -2,7 +2,7 @@ import {Directive, Attribute, ElementRef, Renderer, Input, Output, EventEmitter, import {NgControl} from 'angular2/common'; import {Config} from '../../config/config'; -import {CSS, hasFocus, raf} from '../../util/dom'; +import {CSS, hasFocus} from '../../util/dom'; /** diff --git a/ionic/components/item/item-sliding-gesture.ts b/ionic/components/item/item-sliding-gesture.ts index f7067121ef..edf5b76605 100644 --- a/ionic/components/item/item-sliding-gesture.ts +++ b/ionic/components/item/item-sliding-gesture.ts @@ -2,7 +2,7 @@ import {DIRECTION_RIGHT} from '../../gestures/hammer'; import {DragGesture} from '../../gestures/drag-gesture'; import {List} from '../list/list'; -import {CSS, raf, closest} from '../../util/dom'; +import {CSS, nativeRaf, closest} from '../../util/dom'; export class ItemSlidingGesture extends DragGesture { @@ -108,7 +108,7 @@ export class ItemSlidingGesture extends DragGesture { itemData.hasMouseOut = true; } - raf(() => { + nativeRaf(() => { if (!this.dragEnded && !this.preventDrag) { isItemActive(itemContainerEle, true); this.open(itemContainerEle, newX, false); @@ -146,7 +146,7 @@ export class ItemSlidingGesture extends DragGesture { itemContainerEle.removeEventListener('mouseout', this.onMouseOut); itemData.hasMouseOut = false; - raf(() => { + nativeRaf(() => { this.open(itemContainerEle, restingPoint, true); }); } diff --git a/ionic/components/nav/nav-controller.ts b/ionic/components/nav/nav-controller.ts index 56b172ba83..671f703412 100644 --- a/ionic/components/nav/nav-controller.ts +++ b/ionic/components/nav/nav-controller.ts @@ -8,7 +8,6 @@ import {Keyboard} from '../../util/keyboard'; import {NavParams} from './nav-params'; import {pascalCaseToDashCase, isBlank} from '../../util/util'; import {Portal} from './nav-portal'; -import {raf} from '../../util/dom'; import {SwipeBackGesture} from './swipe-back'; import {Transition} from '../../transitions/transition'; import {ViewController} from './view-controller'; diff --git a/ionic/components/tap-click/activator.ts b/ionic/components/tap-click/activator.ts index 24732bc2d3..7fadd82958 100644 --- a/ionic/components/tap-click/activator.ts +++ b/ionic/components/tap-click/activator.ts @@ -1,4 +1,4 @@ -import {raf, rafFrames} from '../../util/dom'; +import {rafFrames} from '../../util/dom'; export class Activator { diff --git a/ionic/components/tap-click/ripple.ts b/ionic/components/tap-click/ripple.ts index 01f40d971f..f0a0f421f8 100644 --- a/ionic/components/tap-click/ripple.ts +++ b/ionic/components/tap-click/ripple.ts @@ -1,5 +1,5 @@ import {Activator} from './activator'; -import {CSS, raf, rafFrames} from '../../util/dom'; +import {CSS, nativeRaf, rafFrames} from '../../util/dom'; const win: any = window; @@ -22,7 +22,7 @@ export class RippleActivator extends Activator { self._queue.push(activatableEle); this._zone.runOutsideAngular(function() { - raf(function() { + nativeRaf(function() { var i; for (i = 0; i < self._queue.length; i++) { diff --git a/ionic/components/tap-click/tap-click.ts b/ionic/components/tap-click/tap-click.ts index 24b51e4761..ec1eee034e 100644 --- a/ionic/components/tap-click/tap-click.ts +++ b/ionic/components/tap-click/tap-click.ts @@ -137,7 +137,7 @@ export class TapClick { pointerEnd(ev) { let activatableEle = getActivatableTarget(ev.target); - if (activatableEle) { + if (activatableEle && this.startCoord) { this.activator && this.activator.upAction(ev, activatableEle, this.startCoord.x, this.startCoord.y); } this.moveListeners(false); diff --git a/ionic/components/virtual-scroll/virtual-scroll.ts b/ionic/components/virtual-scroll/virtual-scroll.ts index 80e64d0a02..d8fea3201d 100644 --- a/ionic/components/virtual-scroll/virtual-scroll.ts +++ b/ionic/components/virtual-scroll/virtual-scroll.ts @@ -8,7 +8,7 @@ import {VirtualItem, VirtualHeader, VirtualFooter} from './virtual-item'; import {VirtualCell, VirtualNode, VirtualData} from './virtual-util'; import {processRecords, populateNodeData, initReadNodes, writeToNodes, updateDimensions, adjustRendered, calcDimensions, estimateHeight} from './virtual-util'; import {isBlank, isPresent, isFunction} from '../../util/util'; -import {rafFrames, raf, cancelRaf, pointerCoord, nativeTimeout, clearNativeTimeout} from '../../util/dom'; +import {rafFrames, nativeRaf, cancelRaf, pointerCoord, nativeTimeout, clearNativeTimeout} from '../../util/dom'; import {Img} from '../img/img'; @@ -391,7 +391,7 @@ export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { // oh no! the DOM doesn't have good data yet! // let's try again in XXms, and give up eventually if we never get data attempts++; - raf(function() { + nativeRaf(function() { readDimensions(done); }); } @@ -439,7 +439,7 @@ export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { this._cd.detectChanges(); // wait a frame before trying to read and calculate the dimensions - raf(this.postRenderVirtual.bind(this)); + nativeRaf(this.postRenderVirtual.bind(this)); } /** diff --git a/ionic/util/dom.ts b/ionic/util/dom.ts index d233eaa031..d8dae8e354 100644 --- a/ionic/util/dom.ts +++ b/ionic/util/dom.ts @@ -25,7 +25,10 @@ })(); // use native raf rather than the zone wrapped one -export const raf = (window[window['Zone']['__symbol__']('requestAnimationFrame')] || window[window['Zone']['__symbol__']('webkitRequestAnimationFrame')])['bind'](window); +export const nativeRaf = (window[window['Zone']['__symbol__']('requestAnimationFrame')] || window[window['Zone']['__symbol__']('webkitRequestAnimationFrame')])['bind'](window); + +// zone wrapped raf +export const raf = window.requestAnimationFrame.bind(window); export const cancelRaf = window.cancelAnimationFrame.bind(window); export const nativeTimeout = window[window['Zone']['__symbol__']('setTimeout')]['bind'](window); @@ -35,11 +38,11 @@ export function rafFrames(framesToWait, callback) { framesToWait = Math.ceil(framesToWait); if (framesToWait < 2) { - raf(callback); + nativeRaf(callback); } else { nativeTimeout(() => { - raf(callback); + nativeRaf(callback); }, (framesToWait - 1) * 16.6667); } } diff --git a/ionic/util/keyboard.ts b/ionic/util/keyboard.ts index aa6af126bc..182a23a957 100644 --- a/ionic/util/keyboard.ts +++ b/ionic/util/keyboard.ts @@ -2,7 +2,7 @@ import {Injectable, NgZone} from 'angular2/core'; import {Config} from '../config/config'; import {Form} from './form'; -import {hasFocusedTextInput, raf, rafFrames, nativeTimeout} from './dom'; +import {hasFocusedTextInput, nativeRaf, rafFrames, nativeTimeout} from './dom'; /** * @name Keyboard @@ -109,7 +109,7 @@ export class Keyboard { */ close() { console.debug('keyboard close()'); - raf(() => { + nativeRaf(() => { if (hasFocusedTextInput()) { // only focus out when a text input has focus this._form.focusOut(); @@ -137,7 +137,7 @@ export class Keyboard { let isKeyInputEnabled = false; function cssClass() { - raf(() => { + nativeRaf(() => { document.body.classList[isKeyInputEnabled ? 'add' : 'remove']('focus-outline'); }); } diff --git a/ionic/util/scroll-view.ts b/ionic/util/scroll-view.ts index c0ab1e7d28..423775bd6e 100644 --- a/ionic/util/scroll-view.ts +++ b/ionic/util/scroll-view.ts @@ -1,4 +1,4 @@ -import {CSS, pointerCoord, raf, cancelRaf} from '../util/dom'; +import {CSS, pointerCoord, nativeRaf, cancelRaf} from '../util/dom'; export class ScrollView { @@ -84,7 +84,7 @@ export class ScrollView { } if (easedT < 1) { - raf(step); + nativeRaf(step); } else { // done @@ -96,9 +96,9 @@ export class ScrollView { self.isPlaying = true; // chill out for a frame first - raf(() => { + nativeRaf(() => { startTime = Date.now(); - raf(step); + nativeRaf(step); }); }); @@ -232,7 +232,7 @@ export class ScrollView { // ******** DOM READ **************** this._setMax(); - this._rafId = raf(this._decelerate.bind(this)); + this._rafId = nativeRaf(this._decelerate.bind(this)); } } @@ -260,7 +260,7 @@ export class ScrollView { self.setTop(self._top); if (self._top > 0 && self._top < self._max && Math.abs(self._velocity) > MIN_VELOCITY_CONTINUE_DECELERATION) { - self._rafId = raf(self._decelerate.bind(self)); + self._rafId = nativeRaf(self._decelerate.bind(self)); } } } From aa9a667a3f8031248e55d6326d24c90d83703c6c Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Tue, 26 Apr 2016 16:03:44 -0500 Subject: [PATCH 3/3] feat(picker): add ios/md/wp picker styles --- ionic/components/picker/picker.ios.scss | 118 +++++-- ionic/components/picker/picker.md.scss | 156 +++++++++ ionic/components/picker/picker.scss | 65 +++- ionic/components/picker/picker.ts | 327 ++++++++++++++----- ionic/components/picker/picker.wp.scss | 168 ++++++++++ ionic/components/picker/test/basic/index.ts | 224 ++++++++++--- ionic/components/picker/test/basic/main.html | 26 +- ionic/config/modes.ts | 1 + 8 files changed, 932 insertions(+), 153 deletions(-) diff --git a/ionic/components/picker/picker.ios.scss b/ionic/components/picker/picker.ios.scss index b8b68cef65..6e58e3811e 100644 --- a/ionic/components/picker/picker.ios.scss +++ b/ionic/components/picker/picker.ios.scss @@ -5,45 +5,59 @@ // -------------------------------------------------- $picker-ios-height: 260px !default; -$picker-ios-background-color: #cfd5da !default; +$picker-ios-border-color: $list-ios-border-color !default; +$picker-ios-background-color: $list-ios-background-color !default; $picker-ios-toolbar-height: 44px !default; -$picker-ios-toolbar-background-color: #f7f7f8 !default; +$picker-ios-toolbar-background-color: $picker-ios-background-color !default; $picker-ios-button-height: $picker-ios-toolbar-height !default; -$picker-ios-button-text-color: color($colors-ios, primary) !default; +$picker-ios-button-text-color: $link-ios-color !default; $picker-ios-button-background-color: transparent !default; -$picker-ios-option-offset-y: 90px !default; -$picker-ios-option-font-size: 18px !default; -$picker-ios-option-line-height: 24px !default; +$picker-ios-column-padding: 0 12px !default; + +$picker-ios-option-padding: 0 10px !default; +$picker-ios-option-text-color: $list-ios-text-color !default; +$picker-ios-option-font-size: 22px !default; +$picker-ios-option-height: 42px !default; +$picker-ios-option-offset-y: (($picker-ios-height - $picker-ios-toolbar-height) / 2) - ($picker-ios-option-height / 2) - 10 !default; + +$picker-highlight-opacity: .8 !default; .picker-wrapper { height: $picker-ios-height; - border-top: 1px solid #929499; + border-top: 1px solid $picker-ios-border-color; background: $picker-ios-background-color; } -.hairlines .picker-wrapper { - border-width: $hairlines-width; -} - .picker-toolbar { display: flex; height: $picker-ios-toolbar-height; + + border-bottom: 1px solid $picker-ios-border-color; + background: $picker-ios-toolbar-background-color; } +.hairlines .picker-wrapper, +.hairlines .picker-toolbar { + border-width: $hairlines-width; +} + .picker-toolbar-button { flex: 1; + text-align: right; } .picker-toolbar-cancel { + font-weight: normal; + text-align: left; } @@ -57,23 +71,85 @@ $picker-ios-option-line-height: 24px !default; background: $picker-ios-button-background-color; } -.picker-offset { - transform: translateY($picker-ios-option-offset-y); +.picker-columns { + height: $picker-ios-height - $picker-ios-toolbar-height; + + perspective: 1800px; } -.picker-column { - padding: 0 10px; +.picker-col { + padding: $picker-ios-column-padding; + + transform-style: preserve-3d; } .picker-prefix, .picker-suffix, -.picker-options { - padding: 0 8px; +.picker-opts { + top: $picker-ios-option-offset-y; + font-size: $picker-ios-option-font-size; - line-height: $picker-ios-option-line-height; + line-height: $picker-ios-option-height; + color: $picker-ios-option-text-color; + + transform-style: preserve-3d; + + pointer-events: none; } -.picker-prefix, -.picker-suffix { - padding: 0 +.picker-opt { + margin: 0; + padding: $picker-ios-option-padding; + + width: calc(100% - 24px); + + font-size: $picker-ios-option-font-size; + line-height: $picker-ios-option-height; + + background: transparent; + transform-origin: center center; + transform-style: preserve-3d; + transition-timing-function: ease-out; + + backface-visibility: hidden; + + pointer-events: auto; +} + +.picker-above-highlight { + position: absolute; + top: 0; + left: 0; + z-index: 10; + display: block; + + width: 100%; + height: $picker-ios-option-offset-y + 4px; + + border-bottom: 1px solid $picker-ios-border-color; + + background: linear-gradient(to bottom, + rgba($picker-ios-background-color, 1) 20%, + rgba($picker-ios-background-color, .7) 100%); + + transform: translate3d(0, 0, 90px); +} + +.picker-below-highlight { + position: absolute; + top: $picker-ios-option-offset-y + $picker-ios-option-height - 4; + left: 0; + z-index: 11; + display: block; + + width: 100%; + height: $picker-ios-option-offset-y + $picker-ios-option-height; + + border-top: 1px solid $picker-ios-border-color; + + background: linear-gradient(to top, + rgba($picker-ios-background-color, 1) 30%, + rgba($picker-ios-background-color, .7) 100%); + + transform: translate3d(0, 0, 90px); } diff --git a/ionic/components/picker/picker.md.scss b/ionic/components/picker/picker.md.scss index 6e999c22ce..825c72d3ca 100644 --- a/ionic/components/picker/picker.md.scss +++ b/ionic/components/picker/picker.md.scss @@ -3,3 +3,159 @@ // Material Design Picker // -------------------------------------------------- + +$picker-md-height: 260px !default; +$picker-md-border-color: $list-md-border-color !default; +$picker-md-background-color: $list-md-background-color !default; + +$picker-md-toolbar-height: 44px !default; +$picker-md-toolbar-background-color: $picker-md-background-color !default; + +$picker-md-button-height: $picker-md-toolbar-height !default; +$picker-md-button-text-color: $link-md-color !default; +$picker-md-button-background-color: transparent !default; + +$picker-md-column-padding: 0 12px !default; + +$picker-md-option-padding: 0 10px !default; +$picker-md-option-text-color: $list-md-text-color !default; +$picker-md-option-font-size: 18px !default; +$picker-md-option-height: 42px !default; +$picker-md-option-offset-y: (($picker-md-height - $picker-md-toolbar-height) / 2) - ($picker-md-option-height / 2) - 10 !default; + +$picker-md-option-selected-font-size: 24px !default; +$picker-md-option-selected-color: $link-md-color !default; + +$picker-highlight-opacity: .8 !default; + + +.picker-wrapper { + height: $picker-md-height; + + border-top: 1px solid $picker-md-border-color; + + background: $picker-md-background-color; +} + +.picker-toolbar { + display: flex; + + justify-content: flex-end; + + height: $picker-md-toolbar-height; + + background: $picker-md-toolbar-background-color; +} + +.hairlines .picker-wrapper, +.hairlines .picker-toolbar { + border-width: $hairlines-width; +} + +.picker-button, +.picker-button.activated { + margin: 0; + + height: $picker-md-button-height; + + color: $picker-md-button-text-color; + background: $picker-md-button-background-color; + + box-shadow: none; +} + +.picker-columns { + height: $picker-md-height - $picker-md-toolbar-height; + + perspective: 1800px; +} + +.picker-col { + padding: $picker-md-column-padding; + + transform-style: preserve-3d; +} + +.picker-prefix, +.picker-suffix, +.picker-opts { + top: $picker-md-option-offset-y; + + font-size: $picker-md-option-font-size; + line-height: $picker-md-option-height; + color: $picker-md-option-text-color; + + transform-style: preserve-3d; + + pointer-events: none; +} + +.picker-opts ion-button-effect { + display: none; +} + +.picker-opt { + margin: 0; + padding: $picker-md-option-padding; + + width: calc(100% - 24px); + + font-size: $picker-md-option-font-size; + line-height: $picker-md-option-height; + + background: transparent; + + transition-timing-function: ease-out; + + backface-visibility: hidden; + + pointer-events: auto; +} + +.picker-opt .button-inner { + transition: 200ms; +} + +.picker-prefix, +.picker-suffix, +.picker-opt-selected { + font-size: $picker-md-option-selected-font-size; + + color: $picker-md-option-selected-color; +} + +.picker-above-highlight { + position: absolute; + top: 0; + left: 0; + z-index: 10; + + width: 100%; + height: $picker-md-option-offset-y + 4px; + + border-bottom: 1px solid $picker-md-border-color; + + background: linear-gradient(to bottom, + rgba($picker-md-background-color, 1) 20%, + rgba($picker-md-background-color, .7) 100%); + + transform: translate3d(0, 0, 90px); +} + +.picker-below-highlight { + position: absolute; + top: $picker-md-option-offset-y + $picker-md-option-height - 4; + left: 0; + z-index: 11; + + width: 100%; + height: $picker-md-option-offset-y + $picker-md-option-height; + + border-top: 1px solid $picker-md-border-color; + + background: linear-gradient(to top, + rgba($picker-md-background-color, 1) 30%, + rgba($picker-md-background-color, .7) 100%); + + transform: translate3d(0, 0, 90px); +} diff --git a/ionic/components/picker/picker.scss b/ionic/components/picker/picker.scss index 910b3af9b1..792f3698fe 100644 --- a/ionic/components/picker/picker.scss +++ b/ionic/components/picker/picker.scss @@ -29,9 +29,11 @@ ion-picker-cmp { left: 0; z-index: $z-index-overlay-wrapper; display: flex; - flex-direction: column; + overflow: hidden; + flex-direction: column; + margin: auto; width: $picker-width; @@ -41,32 +43,79 @@ ion-picker-cmp { } .picker-columns { + position: relative; display: flex; - flex: 1; + overflow: hidden; + + justify-content: center; } -.picker-offset { +.picker-col { + position: relative; display: flex; + + max-height: 100%; } -.picker-column { - flex: 1; +.picker-opts { + position: relative; + + width: 100%; + min-width: 50px; + max-width: 100%; } .picker-prefix { + position: relative; + flex: 1; + + min-width: 50px; + min-width: 45%; + text-align: right; + white-space: nowrap; } .picker-suffix { + position: relative; + flex: 1; + + min-width: 50px; + min-width: 45%; + text-align: left; + white-space: nowrap; } -.picker-option { - flex: 1; +.picker-opt { + position: absolute; + top: 0; + left: 0; overflow: hidden; - white-space: nowrap; + + flex: 1; + + width: 100%; + + text-align: center; text-overflow: ellipsis; + white-space: nowrap; +} + +.picker-opts-left .button-inner { + justify-content: flex-start; +} + +.picker-opts-right .button-inner { + justify-content: flex-end; +} + +.picker-above-highlight, +.picker-below-highlight { + display: none; + + pointer-events: none; } diff --git a/ionic/components/picker/picker.ts b/ionic/components/picker/picker.ts index 92f1350e38..0c0f2c9758 100644 --- a/ionic/components/picker/picker.ts +++ b/ionic/components/picker/picker.ts @@ -1,38 +1,24 @@ -import {Component, ElementRef, Input, ViewChild, Renderer, HostListener, ChangeDetectionStrategy, ViewEncapsulation} from 'angular2/core'; -import {NgClass, NgIf, NgFor} from 'angular2/common'; +import {Component, ElementRef, Input, ViewChild, Renderer, HostListener, ViewEncapsulation} from 'angular2/core'; import {Animation} from '../../animations/animation'; import {Transition, TransitionOptions} from '../../transitions/transition'; import {Config} from '../../config/config'; -import {isPresent} from '../../util/util'; +import {isPresent, isString, isNumber} from '../../util/util'; import {NavParams} from '../nav/nav-params'; import {ViewController} from '../nav/view-controller'; -import {raf, CSS, pointerCoord} from '../../util/dom'; - +import {nativeRaf, cancelRaf, CSS, pointerCoord} from '../../util/dom'; /** * @name Picker * @description * - * @usage - * ```ts - * constructor(private nav: NavController) {} - * - * presentSelector() { - * let picker = Picker.create({ - * - * }); - * this.nav.present(picker); - * } - * - * ``` - * */ export class Picker extends ViewController { constructor(opts: PickerOptions = {}) { opts.columns = opts.columns || []; + opts.buttons = opts.buttons || []; opts.enableBackdropDismiss = isPresent(opts.enableBackdropDismiss) ? !!opts.enableBackdropDismiss : true; super(PickerDisplayCmp, opts); @@ -54,6 +40,20 @@ export class Picker extends ViewController { return this._nav && this._nav.config.get(key); } + /** + * @param {any} button Picker toolbar button + */ + addButton(button: any) { + this.data.buttons.push(button); + } + + /** + * @param {any} button Picker toolbar button + */ + addColumn(column: PickerColumn) { + this.data.columns.push(column); + } + /** * @param {string} cssClass CSS class name to add to the picker's outer wrapper. */ @@ -68,95 +68,140 @@ export class Picker extends ViewController { } - /** * @private */ @Component({ - selector: '.picker-column', + selector: '.picker-col', template: - '
' + - '
{{col.prefix}}
' + - '
' + - '
' + - '{{o.text}}' + - '
' + - '
' + - '
{{col.suffix}}
' + - '
', + '
{{col.prefix}}
' + + '
' + + '' + + '
' + + '
{{col.suffix}}
', host: { - '[style.flex]': 'col.flex', + '[style.min-width]': 'col.columnWidth', + '[class.picker-opts-left]': 'col.align=="left"', + '[class.picker-opts-right]': 'col.align=="right"', '(touchstart)': 'pointerStart($event)', '(touchmove)': 'pointerMove($event)', '(touchend)': 'pointerEnd($event)', '(mousedown)': 'pointerStart($event)', '(mousemove)': 'pointerMove($event)', - '(mouseup)': 'pointerEnd($event)', + '(body:mouseup)': 'pointerEnd($event)', + '(body:mouseout)': 'mouseOut($event)', } }) class PickerColumnCmp { @ViewChild('colEle') colEle: ElementRef; @Input() col: PickerColumn; - y: number; + y: number = 0; colHeight: number; optHeight: number; velocity: number; pos: number[] = []; - scrollingDown: boolean; msPrv: number = 0; startY: number = null; + rafId: number; + bounceFrom: number; + maxY: number; + rotateFactor: number; + + constructor(config: Config) { + this.rotateFactor = config.getNumber('pickerRotateFactor', 0); + } ngAfterViewInit() { + // get the scrollable element within the column let colEle: HTMLElement = this.colEle.nativeElement; this.colHeight = colEle.clientHeight; + + // get the height of one option this.optHeight = (colEle.firstElementChild ? colEle.firstElementChild.clientHeight : 0); - this.setY(0, true); + // set the scroll position for the selected option + let selectedIndex = this.col.options.indexOf(this.col.selected); + this.setSelected(selectedIndex, 0); } pointerStart(ev) { + console.debug('picker, pointerStart', ev.type, this.startY); + if (this.isPrevented(ev)) { + // do not both with mouse events if a touch event already fired return; } + // cancel any previous raf's that haven't fired yet + cancelRaf(this.rafId); + + // remember where the pointer started from` this.startY = pointerCoord(ev).y; + // reset everything this.velocity = 0; this.pos.length = 0; this.pos.push(this.startY, Date.now()); - - console.debug('picker, pointerStart', ev.type, this.startY); + this.maxY = (this.optHeight * (this.col.options.length - 1)) * -1; } pointerMove(ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (this.startY !== null) { if (this.isPrevented(ev)) { return; } - let currentY = pointerCoord(ev).y; - console.debug('picker, pointerMove', ev.type, currentY); - + var currentY = pointerCoord(ev).y; this.pos.push(currentY, Date.now()); - this.setY(this.startY + currentY, false); + + // update the scroll position relative to pointer start position + var y = this.y + (currentY - this.startY); + + if (y > 0) { + // scrolling up higher than scroll area + y = Math.pow(y, 0.8); + this.bounceFrom = y; + + } else if (y < this.maxY) { + // scrolling down below scroll area + y = y + Math.pow(this.maxY - y, 0.9); + this.bounceFrom = y; + + } else { + this.bounceFrom = 0; + } + + this.update(y, 0, false); } } pointerEnd(ev) { - if (this.startY !== null) { + if (this.isPrevented(ev)) { + return; + } - if (this.isPrevented(ev)) { - return; - } + this.velocity = 0; + if (this.bounceFrom > 0) { + // bounce back up + this.update(0, 100, true); + + } else if (this.bounceFrom < 0) { + // bounce back down + this.update(this.maxY, 100, true); + + } else if (this.startY !== null) { var endY = pointerCoord(ev).y; console.debug('picker, pointerEnd', ev.type, endY); this.pos.push(endY, Date.now()); - this.velocity = 0; - this.scrollingDown = (endY < this.startY); var endPos = (this.pos.length - 1); var startPos = endPos; @@ -176,45 +221,135 @@ class PickerColumnCmp { this.velocity = ((movedTop / timeOffset) * FRAME_MS); } - this.setY(this.startY + endY, true); + if (Math.abs(endY - this.startY) > 3) { + ev.preventDefault(); + ev.stopPropagation(); - this.decelerate(); + var y = this.y + (endY - this.startY); + this.update(y, 0, true); + } - this.startY = null; + } + + this.startY = null; + this.decelerate(); + } + + mouseOut(ev) { + if (ev.target.classList.contains('picker-col')) { + this.pointerEnd(ev); } } decelerate() { - var self = this; + var y = 0; + cancelRaf(this.rafId); - if (self.velocity) { - self.velocity *= DECELERATION_FRICTION; - console.log(`decelerate velocity ${self.velocity}`); + if (isNaN(this.y) || !this.optHeight) { + // fallback in case numbers get outta wack + this.update(y, 0, true); - var y = self.y + self.velocity; - self.setY(y, true); + } else if (Math.abs(this.velocity) > 0) { + // still decelerating + this.velocity *= DECELERATION_FRICTION; - raf(self.decelerate.bind(self)); + // do not let it go slower than a velocity of 1 + this.velocity = (this.velocity > 0 ? Math.max(this.velocity, 1) : Math.min(this.velocity, -1)); - } else if (self.y % this.optHeight !== 0) { + y = Math.round(this.y - this.velocity); - self.y = self.y + (this.scrollingDown ? -1 : 1); + if (y > 0) { + // whoops, it's trying to scroll up farther than the options we have! + y = 0; + this.velocity = 0; - console.log(`lock in ${self.y}`); + } else if (y < this.maxY) { + // gahh, it's trying to scroll down farther than we can! + y = this.maxY; + this.velocity = 0; + } - self.setY(self.y, true); + console.log(`decelerate y: ${y}, velocity: ${this.velocity}, optHeight: ${this.optHeight}`); - raf(self.decelerate.bind(self)); + this.update(y, 0, true); + + if (y % this.optHeight !== 0 || Math.abs(this.velocity) > 1) { + // isn't locked in yet, keep decelerating until it is + this.rafId = nativeRaf(this.decelerate.bind(this)); + } + + } else if (this.y % this.optHeight !== 0) { + // needs to still get locked into a position so options line up + var currentPos = Math.abs(this.y % this.optHeight); + + // create a velocity in the direction it needs to scroll + this.velocity = (currentPos > (this.optHeight / 2) ? 1 : -1); + + this.decelerate(); } } - setY(yOffset: number, saveY: boolean) { - let y = yOffset + this.y; + optClick(ev, index: number) { + if (!this.velocity) { + ev.preventDefault(); + ev.stopPropagation(); - console.log(`y: ${y}, yOffset: ${yOffset}, colHeight: ${this.colHeight}, optHeight: ${this.optHeight}`); + this.setSelected(index, 150); + } + } - let colEleStyle = this.colEle.nativeElement.style; - colEleStyle[CSS.transform] = `translate3d(0px,${y}px,0px)`; + 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 + let y = (selectedIndex > -1) ? ((selectedIndex * this.optHeight) * -1) : 0; + + cancelRaf(this.rafId); + this.velocity = 0; + + // so what y position we're at + this.update(y, duration, true); + } + + update(y: number, duration: number, saveY: boolean) { + // ensure we've got a good round number :) + y = Math.round(y); + + let selectedIndex = Math.abs(Math.round(y / this.optHeight)); + + this.col.selected = this.col.options[selectedIndex]; + + let colEle: HTMLElement = this.colEle.nativeElement; + let optElements: any = colEle.querySelectorAll('.picker-opt'); + + for (var i = 0; i < optElements.length; i++) { + var optEle: HTMLElement = optElements[i]; + var optTop = (i * this.optHeight); + var optOffset = (optTop + y); + + var rotateX = (optOffset * this.rotateFactor); + var translateX = 0; + var translateY = 0; + var translateZ = 0; + + if (this.rotateFactor !== 0) { + translateX = 10; + translateZ = 90; + if (rotateX > 90 || rotateX < -90) { + translateX = -9999; + rotateX = 0; + } + + } else { + translateY = optOffset; + } + + optEle.style[CSS.transform] = `rotateX(${rotateX}deg) translate3d(${translateX}px,${translateY}px,${translateZ}px)`; + + optEle.style[CSS.transitionDuration] = (duration > 0 ? duration + 'ms' : ''); + + optEle.classList[i === selectedIndex ? 'add' : 'remove']('picker-opt-selected'); + + } if (saveY) { this.y = y; @@ -223,9 +358,12 @@ class PickerColumnCmp { isPrevented(ev) { if (ev.type.indexOf('touch') > -1) { + // this is a touch event, so prevent mouse events for a while this.msPrv = Date.now() + 2000; } else if (this.msPrv > Date.now() && ev.type.indexOf('mouse') > -1) { + // this is a mouse event, and a touch event already happend recently + // prevent the calling method from continuing ev.preventDefault(); ev.stopPropagation(); return true; @@ -245,21 +383,21 @@ class PickerColumnCmp { '
' + '
' + '
' + - '' + '
' + '
' + '
' + - '
' + + '
' + + '
' + + '
' + '
' + '
', host: { 'role': 'dialog' }, - directives: [NgClass, NgIf, NgFor, PickerColumnCmp], - changeDetection: ChangeDetectionStrategy.OnPush, + directives: [PickerColumnCmp], encapsulation: ViewEncapsulation.None, }) class PickerDisplayCmp { @@ -293,7 +431,7 @@ class PickerDisplayCmp { let data = this.d; data.buttons = data.buttons.map(button => { - if (typeof button === 'string') { + if (isString(button)) { return { text: button }; } if (button.role) { @@ -302,10 +440,34 @@ class PickerDisplayCmp { return button; }); + // clean up dat data data.columns = data.columns.map(column => { - if (!column.flex) { - column.flex = 1; + if (!isPresent(column.columnWidth)) { + column.columnWidth = (100 / data.columns.length) + '%'; } + if (!isPresent(column.options)) { + column.options = []; + } + + column.options = column.options.map(inputOpt => { + let opt: PickerColumnOption = { + text: '', + value: '' + }; + + if (isPresent(inputOpt)) { + if (isString(inputOpt) || isNumber(inputOpt)) { + opt.text = inputOpt; + opt.value = inputOpt; + + } else { + opt.text = isPresent(inputOpt.text) ? inputOpt.text : inputOpt.value; + opt.value = isPresent(inputOpt.value) ? inputOpt.value : inputOpt.text; + } + } + + return opt; + }); return column; }); } @@ -384,7 +546,7 @@ class PickerDisplayCmp { // return an object of all the values with the input name as the key let values = {}; this.d.columns.forEach(col => { - values[col.name] = col.value; + values[col.name] = col.selected ? col.selected.value : null; }); return values; } @@ -404,19 +566,20 @@ export interface PickerOptions { export interface PickerColumn { name?: string; - value?: string; + selected?: PickerColumnOption; prefix?: string; suffix?: string; options: PickerColumnOption[]; - flex?: number; cssClass?: string; + columnWidth?: string; + prefixWidth?: string; + suffixWidth?: string; + optionsWidth?: string; } export interface PickerColumnOption { - value?: string; - text?: string; - checked?: boolean; - id?: string; + value?: any; + text?: any; } @@ -458,7 +621,5 @@ Transition.register('picker-slide-out', PickerSlideOut); let pickerIds = -1; -const MIN_VELOCITY_START_DECELERATION = 4; -const MIN_VELOCITY_CONTINUE_DECELERATION = 0.12; const DECELERATION_FRICTION = 0.97; const FRAME_MS = (1000 / 60); diff --git a/ionic/components/picker/picker.wp.scss b/ionic/components/picker/picker.wp.scss index 466cd79ef2..85fdb18cf4 100644 --- a/ionic/components/picker/picker.wp.scss +++ b/ionic/components/picker/picker.wp.scss @@ -3,3 +3,171 @@ // Windows Picker // -------------------------------------------------- + +$picker-wp-height: 260px !default; +$picker-wp-border-color: $list-wp-border-color !default; +$picker-wp-background-color: $list-wp-background-color !default; + +$picker-wp-toolbar-height: 44px !default; +$picker-wp-toolbar-background-color: $picker-wp-background-color !default; + +$picker-wp-button-height: $picker-wp-toolbar-height !default; +$picker-wp-button-text-color: $link-wp-color !default; +$picker-wp-button-background-color: transparent !default; + +$picker-wp-column-padding: 0 12px !default; + +$picker-wp-option-padding: 0 10px !default; +$picker-wp-option-text-color: $list-wp-text-color !default; +$picker-wp-option-font-size: 18px !default; +$picker-wp-option-height: 42px !default; +$picker-wp-option-offset-y: (($picker-wp-height - $picker-wp-toolbar-height) / 2) - ($picker-wp-option-height / 2) - 10 !default; + +$picker-wp-option-selected-font-size: 24px !default; +$picker-wp-option-selected-color: $link-wp-color !default; + +$picker-highlight-opacity: .8 !default; + + +.picker-wrapper { + height: $picker-wp-height; + + border-top: 1px solid $picker-wp-border-color; + + background: $picker-wp-background-color; +} + +.picker-toolbar { + display: flex; + + justify-content: flex-end; + + height: $picker-wp-toolbar-height; + + background: $picker-wp-toolbar-background-color; +} + +.hairlines .picker-wrapper, +.hairlines .picker-toolbar { + border-width: $hairlines-width; +} + +.picker-toolbar-button { + flex: 1; + + text-align: right; +} + +.picker-toolbar-cancel { + font-weight: normal; + + text-align: left; +} + +.picker-button, +.picker-button.activated { + margin: 0; + + height: $picker-wp-button-height; + + color: $picker-wp-button-text-color; + background: $picker-wp-button-background-color; + + box-shadow: none; +} + +.picker-columns { + height: $picker-wp-height - $picker-wp-toolbar-height; + + perspective: 1800px; +} + +.picker-col { + padding: $picker-wp-column-padding; + + transform-style: preserve-3d; +} + +.picker-prefix, +.picker-suffix, +.picker-opts { + top: $picker-wp-option-offset-y; + + font-size: $picker-wp-option-font-size; + line-height: $picker-wp-option-height; + color: $picker-wp-option-text-color; + + transform-style: preserve-3d; + + pointer-events: none; +} + +.picker-opts ion-button-effect { + display: none; +} + +.picker-opt { + margin: 0; + padding: $picker-wp-option-padding; + + width: calc(100% - 24px); + + font-size: $picker-wp-option-font-size; + line-height: $picker-wp-option-height; + + background: transparent; + + transition-timing-function: ease-out; + + backface-visibility: hidden; + + pointer-events: auto; +} + +.picker-opt .button-inner { + transition: 200ms; +} + +.picker-prefix, +.picker-suffix, +.picker-opt-selected { + font-size: $picker-wp-option-selected-font-size; + + color: $picker-wp-option-selected-color; +} + +.picker-above-highlight { + position: absolute; + top: 0; + left: 0; + z-index: 10; + + width: 100%; + height: $picker-wp-option-offset-y + 4px; + + border-bottom: 1px solid $picker-wp-border-color; + + background: linear-gradient(to bottom, + rgba($picker-wp-background-color, 1) 20%, + rgba($picker-wp-background-color, .7) 100%); + + transform: translate3d(0, 0, 90px); +} + +.picker-below-highlight { + position: absolute; + top: $picker-wp-option-offset-y + $picker-wp-option-height - 4; + left: 0; + z-index: 11; + + width: 100%; + height: $picker-wp-option-offset-y + $picker-wp-option-height; + + border-top: 1px solid $picker-wp-border-color; + + background: linear-gradient(to top, + rgba($picker-wp-background-color, 1) 30%, + rgba($picker-wp-background-color, .7) 100%); + + transform: translate3d(0, 0, 90px); +} diff --git a/ionic/components/picker/test/basic/index.ts b/ionic/components/picker/test/basic/index.ts index ac9e51ac1f..1d05985f57 100644 --- a/ionic/components/picker/test/basic/index.ts +++ b/ionic/components/picker/test/basic/index.ts @@ -1,68 +1,212 @@ +import {ViewEncapsulation} from 'angular2/core'; import {App, Page, Picker, NavController} from 'ionic-angular'; @Page({ - templateUrl: 'main.html' + templateUrl: 'main.html', + encapsulation: ViewEncapsulation.None, }) class E2EPage { + smoothie: string; + timer: string; - constructor(private nav: NavController) { - setTimeout(() => { - this.presentPicker() - }, 250); - } + constructor(private nav: NavController) {} - presentPicker() { + twoColumns() { let picker = Picker.create({ buttons: [ { text: 'Cancel', role: 'cancel' }, - 'Done' + { + text: 'Done', + handler: (data) => { + this.smoothie = `${data.flavor1} ${data.flavor2}`; + } + } ], columns: [ { - prefix: 'prefix', - suffix: 'suffix', + name: 'flavor1', + align: 'right', options: [ - { text: 'Jan' }, - { text: 'Feb' }, - { text: 'Mar' }, - { text: 'Apr' }, - { text: 'May' }, - { text: 'Jun' }, - { text: 'Jul' }, - { text: 'Aug' }, - { text: 'Sep' }, - { text: 'Oct' }, - { text: 'Nov' }, - { text: 'Dec' }, + { text: 'Mango' }, + { text: 'Banana' }, + { text: 'Cherry' }, + { text: 'Strawberry' }, + { text: 'Raspberry' }, + { text: 'Blueberry' }, + { text: 'Peach' }, + { text: 'Coconut' }, + { text: 'Pineapple' }, + { text: 'Honeydew' }, + { text: 'Watermelon' }, + { text: 'Grape' }, + { text: 'Avocado' }, + { text: 'Kiwi' }, + { text: 'Orange' }, + { text: 'Papaya' }, + ] + }, + { + name: 'flavor2', + align: 'left', + options: [ + { text: 'Banana' }, + { text: 'Orange' }, + { text: 'Grape' }, + { text: 'Watermelon' }, + { text: 'Strawberry' }, + { text: 'Papaya' }, + { text: 'Kiwi' }, + { text: 'Cherry' }, + { text: 'Raspberry' }, + { text: 'Mango' }, + { text: 'Pineapple' }, + { text: 'Peach' }, + { text: 'Avocado' }, + { text: 'Honeydew' }, + { text: 'Blueberry' }, + { text: 'Coconut' }, ] }, - // { - // prefix: 'prefix', - // suffix: 'suffix', - // options: [ - // { text: 'Jan' }, - // { text: 'Feb' }, - // { text: 'Mar' }, - // { text: 'Apr' }, - // { text: 'May' }, - // { text: 'Jun' }, - // { text: 'Jul' }, - // { text: 'Aug' }, - // { text: 'Sep' }, - // { text: 'Oct' }, - // { text: 'Nov' }, - // { text: 'Dec' }, - // ] - // }, ] }); this.nav.present(picker); } + + prefixLabel() { + let picker = Picker.create({ + buttons: [ + { + text: 'Nerp', + role: 'cancel' + }, + { + text: 'Woot!', + handler: (data) => { + this.smoothie = `${data.flavor1}`; + } + } + ], + columns: [ + { + name: 'flavor1', + align: 'left', + prefix: 'Flavor', + options: [ + { text: 'Mango' }, + { text: 'Banana' }, + { text: 'Cherry' }, + { text: 'Strawberry' }, + { text: 'Raspberry' }, + { text: 'Blueberry' }, + { text: 'Peach' }, + { text: 'Coconut' }, + { text: 'Pineapple' }, + { text: 'Honeydew' }, + { text: 'Watermelon' }, + { text: 'Grape' }, + { text: 'Avocado' }, + { text: 'Kiwi' }, + { text: 'Orange' }, + { text: 'Papaya' }, + ] + } + ] + }); + + this.nav.present(picker); + } + + suffixLabel() { + let picker = Picker.create({ + buttons: [ + { + text: 'No', + role: 'cancel' + }, + { + text: 'Si', + handler: (data) => { + this.smoothie = `${data.flavor1}`; + } + } + ], + columns: [ + { + name: 'flavor1', + align: 'right', + suffix: 'flavor', + options: [ + { text: 'Mango' }, + { text: 'Banana' }, + { text: 'Cherry' }, + { text: 'Strawberry' }, + { text: 'Raspberry' }, + { text: 'Blueberry' }, + { text: 'Peach' }, + { text: 'Coconut' }, + { text: 'Pineapple' }, + { text: 'Honeydew' }, + { text: 'Watermelon' }, + { text: 'Grape' }, + { text: 'Avocado' }, + { text: 'Kiwi' }, + { text: 'Orange' }, + { text: 'Papaya' }, + ] + } + ] + }); + + this.nav.present(picker); + } + + columnSizes() { + let picker = Picker.create(); + + picker.addButton({ + text: 'Cancel', + role: 'cancel' + }); + + picker.addButton({ + text: 'Set Timer', + handler: (data) => { + this.timer = `${data.hour}:${data.min}`; + } + }); + + picker.addColumn({ + name: 'hour', + suffix: 'hour', + columnWidth: '30%', + optionsWidth: '50px', + options: Array.apply(null, {length: 23}).map(Number.call, Number) + }); + + var minuteOptions = []; + + for (var i = 0; i < 60; i++) { + minuteOptions.push({ + text: i, + value: ('0' + i).slice(-2) + }); + } + + picker.addColumn({ + name: 'min', + suffix: 'min', + columnWidth: '40%', + optionsWidth: '80px', + options: minuteOptions + }); + + this.nav.present(picker); + } } diff --git a/ionic/components/picker/test/basic/main.html b/ionic/components/picker/test/basic/main.html index 90bd279550..fa80234cb4 100644 --- a/ionic/components/picker/test/basic/main.html +++ b/ionic/components/picker/test/basic/main.html @@ -4,6 +4,30 @@ - + + + + + + + + + + + + \ No newline at end of file diff --git a/ionic/config/modes.ts b/ionic/config/modes.ts index 60142a2ebb..9d6d1dffca 100644 --- a/ionic/config/modes.ts +++ b/ionic/config/modes.ts @@ -33,6 +33,7 @@ Config.setModeConfig('ios', { pickerEnter: 'picker-slide-in', pickerLeave: 'picker-slide-out', + pickerRotateFactor: -0.46, spinner: 'ios',