From 1e331c9ca079e9ec2d8a5cb672f40cbf07f9b473 Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Fri, 13 May 2016 21:00:47 -0500 Subject: [PATCH] feat(datetime): add ion-datetime --- ionic/components.ios.scss | 1 + ionic/components.ts | 1 + ionic/components/datetime/datetime.ios.scss | 15 + ionic/components/datetime/datetime.scss | 30 + ionic/components/datetime/datetime.ts | 888 ++++++++++++++++++ ionic/components/datetime/test/basic/e2e.ts | 8 + ionic/components/datetime/test/basic/index.ts | 41 + .../components/datetime/test/basic/main.html | 68 ++ .../components/datetime/test/datetime.spec.ts | 518 ++++++++++ ionic/components/item/item.ts | 2 +- ionic/components/picker/picker.ios.scss | 11 +- ionic/components/picker/picker.md.scss | 8 +- ionic/components/picker/picker.scss | 17 +- ionic/components/picker/picker.ts | 190 ++-- ionic/components/picker/picker.wp.scss | 8 +- ionic/components/select/select.ts | 10 +- ionic/config/directives.ts | 5 +- ionic/util.ts | 1 + ionic/util/datetime-util.ts | 500 ++++++++++ ionic/util/test/datetime-util.spec.ts | 792 ++++++++++++++++ ionic/util/test/util.spec.ts | 685 +++++++------- 21 files changed, 3374 insertions(+), 425 deletions(-) create mode 100644 ionic/components/datetime/datetime.ios.scss create mode 100644 ionic/components/datetime/datetime.scss create mode 100644 ionic/components/datetime/datetime.ts create mode 100644 ionic/components/datetime/test/basic/e2e.ts create mode 100644 ionic/components/datetime/test/basic/index.ts create mode 100644 ionic/components/datetime/test/basic/main.html create mode 100644 ionic/components/datetime/test/datetime.spec.ts create mode 100644 ionic/util/datetime-util.ts create mode 100644 ionic/util/test/datetime-util.spec.ts diff --git a/ionic/components.ios.scss b/ionic/components.ios.scss index 797def8943..d92468af94 100644 --- a/ionic/components.ios.scss +++ b/ionic/components.ios.scss @@ -14,6 +14,7 @@ "components/checkbox/checkbox.ios", "components/chip/chip.ios", "components/content/content.ios", + "components/datetime/datetime.ios", "components/input/input.ios", "components/item/item.ios", "components/label/label.ios", diff --git a/ionic/components.ts b/ionic/components.ts index cb5185b5d3..75898f1580 100644 --- a/ionic/components.ts +++ b/ionic/components.ts @@ -5,6 +5,7 @@ export * from './components/badge/badge'; export * from './components/button/button'; export * from './components/checkbox/checkbox'; export * from './components/content/content'; +export * from './components/datetime/datetime'; export * from './components/icon/icon'; export * from './components/img/img'; export * from './components/infinite-scroll/infinite-scroll'; diff --git a/ionic/components/datetime/datetime.ios.scss b/ionic/components/datetime/datetime.ios.scss new file mode 100644 index 0000000000..a51b089746 --- /dev/null +++ b/ionic/components/datetime/datetime.ios.scss @@ -0,0 +1,15 @@ +@import "../../globals.ios"; +@import "./datetime"; + +// iOS DateTime +// -------------------------------------------------- + +$datetime-ios-padding-top: $item-ios-padding-top !default; +$datetime-ios-padding-right: ($item-ios-padding-right / 2) !default; +$datetime-ios-padding-bottom: $item-ios-padding-bottom !default; +$datetime-ios-padding-left: $item-ios-padding-left !default; + + +ion-datetime { + padding: $datetime-ios-padding-top $datetime-ios-padding-right $datetime-ios-padding-bottom $datetime-ios-padding-left; +} diff --git a/ionic/components/datetime/datetime.scss b/ionic/components/datetime/datetime.scss new file mode 100644 index 0000000000..4abc2cc8a2 --- /dev/null +++ b/ionic/components/datetime/datetime.scss @@ -0,0 +1,30 @@ +@import "../../globals.core"; + +// DateTime +// -------------------------------------------------- + +ion-datetime { + display: flex; + overflow: hidden; + + max-width: 45%; +} + +.datetime-text { + overflow: hidden; + + flex: 1; + + min-width: 16px; + + font-size: inherit; + text-overflow: ellipsis; + white-space: nowrap; +} + +.datetime-disabled, +.item-datetime-disabled ion-label { + opacity: .4; + + pointer-events: none; +} diff --git a/ionic/components/datetime/datetime.ts b/ionic/components/datetime/datetime.ts new file mode 100644 index 0000000000..a870ea5f46 --- /dev/null +++ b/ionic/components/datetime/datetime.ts @@ -0,0 +1,888 @@ +import {Component, Optional, ElementRef, Renderer, Input, Output, Provider, forwardRef, EventEmitter, HostListener, ViewEncapsulation} from 'angular2/core'; +import {NG_VALUE_ACCESSOR} from 'angular2/common'; + +import {Config} from '../../config/config'; +import {Picker, PickerColumn, PickerColumnOption} from '../picker/picker'; +import {Form} from '../../util/form'; +import {Item} from '../item/item'; +import {merge, isBlank, isPresent, isTrueProperty, isArray, isString} from '../../util/util'; +import {dateValueRange, renderDateTime, renderTextFormat, convertFormatToKey, getValueFromFormat, parseTemplate, parseDate, updateDate, DateTimeData, convertDataToISO, daysInMonth, dateSortValue, dateDataSortValue, LocaleData} from '../../util/datetime-util'; +import {NavController} from '../nav/nav-controller'; + +const DATETIME_VALUE_ACCESSOR = new Provider( + NG_VALUE_ACCESSOR, {useExisting: forwardRef(() => DateTime), multi: true}); + + +/** + * @name DateTime + * @description + * The `ion-datetime` component is similar to an HTML `` + * input, however, Ionic's datetime component makes it easier for developers to + * display an exact datetime input format and manage values within JavaScript. + * Additionally, the datetime component makes it easier for users to scroll through + * and individually select parts of date and time values from an easy to user interface. + * + * ```html + * + * Date + * + * + * + * ``` + * + * + * ### Display and Picker Formats + * + * How datetime values can be displayed can come in many variations formats, + * therefore it is best to let the app decide exactly how to display it. To do + * so, `ion-datetime` uses a common format seen in many other libraries and + * programming languages: + * + * | Format | Description | Examples | + * |----------|---------------------|----------------| + * | `YYYY` | Year, 4 digits | `2018` | + * | `YY` | Year, 2 digits | `18` | + * | `M` | Month, 1 digit | `1` .. `12` | + * | `MM` | Month, 2 digit | `01` .. `12` | + * | `MMM` | Month, short name | `Jan` * | + * | `MMMM` | Month, full name | `January` * | + * | `D` | Day, 1 digit | `1` .. `31` | + * | `DD` | Day, 2 digit | `01` .. `31` | + * | `DDD` | Day, short name | `Fri` * | + * | `DDDD` | Day, full name | `Friday` * | + * | `H` | 24-hour, 1 digit | `0` .. `23` | + * | `HH` | 24-hour, 2 digit | `00` .. `23` | + * | `h` | 12-hour, 1 digit | `1` .. `12` | + * | `hh` | 12-hour, 2 digit | `01` .. `12` | + * | `a` | am/pm, lower case | `am` `pm` | + * | `A` | AM/PM, upper case | `AM` `PM` | + * | `m` | minute, 1 digit | `1` .. `59` | + * | `mm` | minute, 2 digit | `01` .. `59` | + * | `s` | seconds, 1 digit | `1` .. `59` | + * | `ss` | seconds, 2 digit | `01` .. `59` | + * | `Z` | UTC Timezone Offset | | + * + * * See the "Month Names and Day of the Week Names" section below on how to + * use names other than the default English month and day names. + * + * The `displayFormat` input allows developers to specify how a date's value + * should be displayed within the `ion-datetime`. The `pickerFormat` decides + * which datetime picker columns should be shown, the order of the columns, and + * which format to display the value in. If a `pickerFormat` is not provided + * then it'll use the `displayFormat` instead. In most cases only providing the + * `displayFormat` is needed. + * + * In the example below, the datetime's display would use the month's short + * name, the 1 digit day in the month, and a 4 digit year. + * + * ```html + * + * Date + * + * + * + * ``` + * + * In this example, the datetime's display would only show hours and minutes, + * and the hours would be in the 24-hour format. Note that the divider between + * the hours and minutes, in this case the `:` character, can be have any + * character which the app chooses to use as the divider. + * + * ```html + * + * Date + * + * + * + * ``` + * + * + * ### Datetime Data + * + * Historically, handling datetime data within JavaScript, or even within HTML + * inputs, has always been a challenge. Specifically, JavaScript's `Date` object is + * notoriously difficult to correctly parse apart datetime strings or to format + * datetime values. Even worse is how different browsers and JavaScript versions + * parse various datetime strings differently, especially per locale. Additional, + * developers face even more challenges when dealing with timezones using + * JavaScript's core `Date` object. + * + * But no worries, all is not lost! Ionic's datetime input has been designed so + * developers can avoid the common pitfalls, allowing developers to easily format + * datetime data within the input, and give the user a simple datetime picker for a + * great user experience. Oddly enough, one of the best ways to work with datetime + * values in JavaScript is to not use the `Date` object at all. + * + * ##### ISO 8601 Datetime Format: YYYY-MM-DDTHH:mmZ + * + * For all the reasons above, and how datetime data is commonly saved within databases, + * Ionic uses the [ISO 8601 datetime format](https://www.w3.org/TR/NOTE-datetime) + * for both its input value, and output value. The value is simply a string, rather + * than using JavaScript's `Date` object, and it strictly follows the standardized + * ISO 8601 format. Additionally, when using the ISO datetime string format, it makes + * it easier on developers when passing data within JSON objects, and sending databases + * a standardized datetime format which it can be easily parse apart and formatted. + * Because of the strict adherence to the ISO 8601 format, and not involving the hundreds + * of other format possibilities and locales, this approach actually makes it easier + * for Ionic apps and backend-services to manage datetime data. + * + * An ISO format can be used as a simple year, or just the hour and minute, or get more + * detailed down to the millisecond and timezone. Any of the ISO formats below can be used, + * and after a user selects a new date, Ionic will continue to use the same ISO format + * which datetime value was originally given as. + * + * | Description | Format | Datetime Value Example | + * |----------------------|------------------------|------------------------------| + * | Year | YYYY | 1994 | + * | Year and Month | YYYY-MM | 1994-12 | + * | Complete Date | YYYY-MM-DD | 1994-12-15 | + * | Date and Time | YYYY-MM-DDTHH:mm | 1994-12-15T13:47 | + * | UTC Timezone | YYYY-MM-DDTHH:mm:ssTZD | 1994-12-15T13:47:20.789Z | + * | Timezone Offset | YYYY-MM-DDTHH:mm:ssTZD | 1994-12-15T13:47:20.789+5:00 | + * | Hour and Minute | HH:mm | 13:47 | + * | Hour, Minute, Second | HH:mm:ss | 13:47:20 | + * + * Note that the year is always four-digits, milliseconds (if it's added) is always + * three-digits, and all others are always two-digits. So the number representing + * January always has a leading zero, such as `01`. Additionally, the hour is always + * in the 24-hour format, so `00` is `12am` on a 12-hour clock, `13` means `1pm`, + * and `23` means `11pm`. + * + * It's also important to note that neither the `displayFormat` or `pickerFormat` can + * set the datetime value's output, which is the value that sent the the component's + * `ngModel`. The format's are merely for displaying the value as text and the picker's + * interface, but the datetime's value is always persisted as a valid ISO 8601 datetime + * string. + * + * + * ### Min and Max Datetimes + * + * Dates are infinite in either direction, so for a user selection there should be at + * least some form of restricting the dates can be selected. Be default, the maximum + * date is to the end of the current year, and the minimum date is from the beginning + * of the year that was 100 years ago. + * + * To customize the minimum and maximum datetime values, the `min` and `max` component + * inputs can be provided which may make more sense for the app's use-case, rather + * than the default of the last 100 years. Following the same IS0 8601 format listed + * in the table above, each component can restrict which dates can be selected by the + * user. Below is an example of restricting the date selection between the beginning + * of 2016, and October 31st of 2020: + * + * ```html + * + * Date + * + * + * + * ``` + * + * + * ### Month Names and Day of the Week Names + * + * At this time, there is no one-size-fits-all standard to automatically choose the correct + * language/spelling for a month name, or day of the week name, depending on the language + * or locale. Good news is that there is an + * [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat) + * standard which *most* browsers have adopted. However, at this time the standard has not + * been fully implemented by all popular browsers so Ionic is unavailable to take advantage + * of it *yet*. Additionally, Angular also provides an internationalization service, but it + * is still under heavy development so Ionic does not depend on it at this time. + * + * All things considered, the by far easiest solution is to just provide an array of names + * if the app needs to use names other than the default English version of month and day + * names. The month names and day names can be either configurated at the app level, or + * individual `ion-datetime` level. + * + * ##### App Config Level + * + * ```ts + * @App({ + * config: { + * monthNames: ['janeiro, 'fevereiro', 'mar\u00e7o', ... ], + * monthShortNames: ['jan', 'fev', 'mar', ... ], + * dayNames: ['domingo', 'segunda-feira', 'ter\u00e7a-feira', ... ], + * dayShortNames: ['dom', 'seg', 'ter', ... ], + * } + * }) + * ``` + * + * ##### Component Input Level + * + * ```html + * + * PerĂ­odo + * + * + * ``` + * + * + * ### Advanced Datetime Validation and Manipulation + * + * The datetime picker provides the simplicity of selecting an exact format, and persists + * the datetime values as a string using the standardized + * [ISO 8601 datetime format](https://www.w3.org/TR/NOTE-datetime). + * However, it's important to note that `ion-datetime` does not attempt to solve all + * situtations when validating and manipulating datetime values. If datetime values need + * to be parsed from a certain format, or manipulated (such as adding 5 days to a date, + * subtracting 30 minutes), or even formatting data to a specific locale, then we highly + * recommend using [moment.js](http://momentjs.com/) to "Parse, validate, manipulate, and + * display dates in JavaScript". [Moment.js](http://momentjs.com/) has quickly become + * our goto standard when dealing with datetimes within JavaScript, but Ionic does not + * prepackage this dependency since most apps will not require it, and its locale + * configuration should be decided by the end-developer. + * + * + */ +@Component({ + selector: 'ion-datetime', + template: + '
{{_text}}
' + + '', + host: { + '[class.datetime-disabled]': '_disabled' + }, + providers: [DATETIME_VALUE_ACCESSOR], + encapsulation: ViewEncapsulation.None, +}) +export class DateTime { + private _disabled: any = false; + private _labelId: string; + private _text: string = ''; + private _fn: Function; + private _isOpen: boolean = false; + private _min: DateTimeData; + private _max: DateTimeData; + private _value: DateTimeData = {}; + private _locale: LocaleData = {}; + + /** + * @private + */ + id: string; + + /** + * @input {string} The minimum datetime allowed. Value must be a date string + * following the + * [ISO 8601 datetime format standard](https://www.w3.org/TR/NOTE-datetime), + * such as `1996-12-19`. The format does not have to be specific to an exact + * datetime. For example, the minimum could just be the year, such as `1994`. + * Defaults to the beginning of the year, 100 years ago from today. + */ + @Input() min: string; + + /** + * @input {string} The maximum datetime allowed. Value must be a date string + * following the + * [ISO 8601 datetime format standard](https://www.w3.org/TR/NOTE-datetime), + * `1996-12-19`. The format does not have to be specific to an exact + * datetime. For example, the maximum could just be the year, such as `1994`. + * Defaults to the end of this year. + */ + @Input() max: string; + + /** + * @input {string} The display format of the date and time as text that shows + * within the item. When the `pickerFormat` input is not used, then the + * `displayFormat` is used for both display the formatted text, and determining + * the datetime picker's columns. See the `pickerFormat` input description for + * more info. Defaults to `MMM D, YYYY`. + */ + @Input() displayFormat: string = 'MMM D, YYYY'; + + /** + * @input {string} The format of the date and time picker columns the user selects. + * A datetime input can have one or many datetime parts, each getting their + * own column which allow individual selection of that particular datetime part. For + * example, year and month columns are two individually selectable columns which help + * choose an exact date from the datetime picker. Each column follows the string + * parse format. Defaults to use `displayFormat`. + */ + @Input() pickerFormat: string; + + /** + * @input {string} The text to display on the picker's cancel button. Default: `Cancel`. + */ + @Input() cancelText: string = 'Cancel'; + + /** + * @input {string} The text to display on the picker's "Done" button. Default: `Done`. + */ + @Input() doneText: string = 'Done'; + + /** + * @input {array | string} Values used to create the list of selectable years. By default + * the year values range between the `min` and `max` datetime inputs. However, to + * control exactly which years to display, the `yearValues` input can take either an array + * of numbers, or string of comma separated numbers. For example, to show upcoming and + * recent leap years, then this input's value would be `yearValues="2024,2020,2016,2012,2008"`. + */ + @Input() yearValues: any; + + /** + * @input {array | string} Values used to create the list of selectable months. By default + * the month values range from `1` to `12`. However, to control exactly which months to + * display, the `monthValues` input can take either an array of numbers, or string of + * comma separated numbers. For example, if only summer months should be shown, then this + * input value would be `monthValues="6,7,8"`. Note that month numbers do *not* have a + * zero-based index, meaning January's value is `1`, and December's is `12`. + */ + @Input() monthValues: any; + + /** + * @input {array | string} Values used to create the list of selectable days. By default + * every day is shown for the given month. However, to control exactly which days of + * the month to display, the `dayValues` input can take either an array of numbers, or + * string of comma separated numbers. Note that even if the array days have an invalid + * number for the selected month, like `31` in February, it will correctly not show + * days which are not valid for the selected month. + */ + @Input() dayValues: any; + + /** + * @input {array | string} Values used to create the list of selectable hours. By default + * the hour values range from `1` to `23` for 24-hour, or `1` to `12` for 12-hour. However, + * to control exactly which hours to display, the `hourValues` input can take either an + * array of numbers, or string of comma separated numbers. + */ + @Input() hourValues: any; + + /** + * @input {array | string} Values used to create the list of selectable minutes. By default + * the mintues range from `1` to `59`. However, to control exactly which minutes to display, + * the `minuteValues` input can take either an array of numbers, or string of comma separated + * numbers. For example, if the minute selections should only be every 15 minutes, then + * this input value would be `minuteValues="0,15,30,45"`. + */ + @Input() minuteValues: any; + + /** + * @input {array} Full names for each month name. This can be used to provide + * locale month names. Defaults to English. + */ + @Input() monthNames: any; + + /** + * @input {array} Short abbreviated names for each month name. This can be used to provide + * locale month names. Defaults to English. + */ + @Input() monthShortNames: any; + + /** + * @input {array} Full day of the week names. This can be used to provide + * locale names for each day in the week. Defaults to English. + */ + @Input() dayNames: any; + + /** + * @input {array} Short abbreviated day of the week names. This can be used to provide + * locale names for each day in the week. Defaults to English. + */ + @Input() dayShortNames: any; + + /** + * @input {any} Any addition options that the picker interface can accept. + * See the [Picker API docs](../../picker/Picker) for the picker options. + */ + @Input() pickerOptions: any = {}; + + /** + * @output {any} Any expression to evaluate when the datetime selection has changed. + */ + @Output() change: EventEmitter = new EventEmitter(); + + /** + * @output {any} Any expression to evaluate when the datetime selection was cancelled. + */ + @Output() cancel: EventEmitter = new EventEmitter(); + + constructor( + private _form: Form, + private _config: Config, + @Optional() private _item: Item, + @Optional() private _nav: NavController + ) { + this._form.register(this); + if (_item) { + this.id = 'dt-' + _item.registerInput('datetime'); + this._labelId = 'lbl-' + _item.id; + this._item.setCssClass('item-datetime', true); + } + + if (!_nav) { + console.error('parent required for '); + } + } + + @HostListener('click', ['$event']) + private _click(ev) { + if (ev.detail === 0) { + // do not continue if the click event came from a form submit + return; + } + ev.preventDefault(); + ev.stopPropagation(); + this.open(); + } + + @HostListener('keyup.space', ['$event']) + private _keyup(ev) { + if (!this._isOpen) { + this.open(); + } + } + + /** + * @private + */ + open() { + if (this._disabled) { + return; + } + + console.debug('datetime, open picker'); + + // the user may have assigned some options specifically for the alert + let pickerOptions = merge({}, this.pickerOptions); + + let picker = Picker.create(pickerOptions); + pickerOptions.buttons = [ + { + text: this.cancelText, + role: 'cancel', + handler: () => { + this.cancel.emit(null); + } + }, + { + text: this.doneText, + handler: (data) => { + console.log('datetime, done', data); + this.onChange(data); + this.change.emit(data); + } + } + ]; + + this.generate(picker); + this.validate(picker); + + picker.change.subscribe(() => { + this.validate(picker); + }); + + this._nav.present(picker, pickerOptions); + + this._isOpen = true; + picker.onDismiss(() => { + this._isOpen = false; + }); + } + + /** + * @private + */ + generate(picker: Picker) { + // if a picker format wasn't provided, then fallback + // to use the display format + let template = this.pickerFormat || this.displayFormat; + + if (isPresent(template)) { + // make sure we've got up to date sizing information + this.calcMinMax(); + + // does not support selecting by day name + // automaticallly remove any day name formats + template = template.replace('DDDD', '{~}').replace('DDD', '{~}'); + if (template.indexOf('D') === -1) { + // there is not a day in the template + // replace the day name with a numeric one if it exists + template = template.replace('{~}', 'D'); + } + // make sure no day name replacer is left in the string + template = template.replace(/{~}/g, ''); + + // parse apart the given template into an array of "formats" + parseTemplate(template).forEach(format => { + // loop through each format in the template + // create a new picker column to build up with data + let key = convertFormatToKey(format); + let values: any[]; + + // first see if they have exact values to use for this input + if (isPresent(this[key + 'Values'])) { + // user provide exact values for this date part + values = convertToArrayOfNumbers(this[key + 'Values'], key); + + } else { + // use the default date part values + values = dateValueRange(format, this._min, this._max); + } + + let column: PickerColumn = { + name: key, + options: values.map(val => { + return { + value: val, + text: renderTextFormat(format, val, null, this._locale), + }; + }) + }; + + if (column.options.length) { + // cool, we've loaded up the columns with options + // preselect the option for this column + var selected = column.options.find(opt => opt.value === getValueFromFormat(this._value, format)); + if (selected) { + // set the select index for this column's options + column.selectedIndex = column.options.indexOf(selected); + } + + // add our newly created column to the picker + picker.addColumn(column); + } + }); + + this.divyColumns(picker); + } + } + + /** + * @private + */ + validate(picker: Picker) { + let i: number; + let today = new Date(); + let columns = picker.getColumns(); + + // find the columns used + let yearCol = columns.find(col => col.name === 'year'); + let monthCol = columns.find(col => col.name === 'month'); + let dayCol = columns.find(col => col.name === 'day'); + + let yearOpt: PickerColumnOption; + let monthOpt: PickerColumnOption; + let dayOpt: PickerColumnOption; + + // default to assuming today's year + let selectedYear = today.getFullYear(); + if (yearCol) { + yearOpt = yearCol.options[yearCol.selectedIndex]; + if (yearOpt) { + // they have a selected year value + selectedYear = yearOpt.value; + } + } + + // default to assuming this month has 31 days + let numDaysInMonth = 31; + let selectedMonth; + if (monthCol) { + monthOpt = monthCol.options[monthCol.selectedIndex]; + if (monthOpt) { + // they have a selected month value + selectedMonth = monthOpt.value; + + // calculate how many days are in this month + numDaysInMonth = daysInMonth(selectedMonth, selectedYear); + } + } + + // create sort values for the min/max datetimes + let minCompareVal = dateDataSortValue(this._min); + let maxCompareVal = dateDataSortValue(this._max); + + if (monthCol) { + // enable/disable which months are valid + // to show within the min/max date range + for (i = 0; i < monthCol.options.length; i++) { + monthOpt = monthCol.options[i]; + + // loop through each month and see if it + // is within the min/max date range + monthOpt.disabled = (dateSortValue(selectedYear, monthOpt.value, 31) < minCompareVal || + dateSortValue(selectedYear, monthOpt.value, 1) > maxCompareVal); + } + } + + if (dayCol) { + if (isPresent(selectedMonth)) { + // enable/disable which days are valid + // to show within the min/max date range + for (i = 0; i < 31; i++) { + dayOpt = dayCol.options[i]; + + // loop through each day and see if it + // is within the min/max date range + var compareVal = dateSortValue(selectedYear, selectedMonth, dayOpt.value); + + dayOpt.disabled = (compareVal < minCompareVal || + compareVal > maxCompareVal || + numDaysInMonth <= i); + } + + } else { + // enable/disable which numbers of days to show in this month + for (i = 0; i < 31; i++) { + dayCol.options[i].disabled = (numDaysInMonth <= i); + } + } + } + + picker.refresh(); + } + + /** + * @private + */ + divyColumns(picker: Picker) { + let pickerColumns = picker.getColumns(); + let columns = []; + + pickerColumns.forEach((col, i) => { + columns.push(0); + + col.options.forEach(opt => { + if (opt.text.length > columns[i]) { + columns[i] = opt.text.length; + } + }); + + }); + + if (columns.length === 2) { + var width = Math.max(columns[0], columns[1]); + pickerColumns[0].columnWidth = pickerColumns[1].columnWidth = `${width * 16}px`; + + } else if (columns.length === 3) { + var width = Math.max(columns[0], columns[2]); + pickerColumns[1].columnWidth = `${columns[1] * 16}px`; + pickerColumns[0].columnWidth = pickerColumns[2].columnWidth = `${width * 16}px`; + + } else if (columns.length > 3) { + columns.forEach((col, i) => { + pickerColumns[i].columnWidth = `${col * 12}px`; + }); + } + } + + /** + * @private + */ + setValue(newData: any) { + updateDate(this._value, newData); + } + + /** + * @private + */ + getValue(): DateTimeData { + return this._value; + } + + /** + * @private + */ + updateText() { + // create the text of the formatted data + this._text = renderDateTime(this.displayFormat, this._value, this._locale); + } + + /** + * @private + */ + calcMinMax() { + let todaysYear = new Date().getFullYear(); + + if (isBlank(this.min)) { + if (isPresent(this.yearValues)) { + this.min = Math.min.apply(Math, convertToArrayOfNumbers(this.yearValues, 'year')); + + } else { + this.min = (todaysYear - 100).toString(); + } + } + + if (isBlank(this.max)) { + if (isPresent(this.yearValues)) { + this.max = Math.max.apply(Math, convertToArrayOfNumbers(this.yearValues, 'year')); + + } else { + this.max = todaysYear.toString(); + } + } + + let min = this._min = parseDate(this.min); + let max = this._max = parseDate(this.max); + + min.month = min.month || 1; + min.day = min.day || 1; + min.hour = min.hour || 0; + min.minute = min.minute || 0; + min.second = min.second || 0; + + max.month = max.month || 12; + max.day = max.day || 31; + max.hour = max.hour || 23; + max.minute = max.minute || 59; + max.second = max.second || 59; + } + + /** + * @input {boolean} Whether or not the datetime component is disabled. Default `false`. + */ + @Input() + get disabled() { + return this._disabled; + } + + set disabled(val) { + this._disabled = isTrueProperty(val); + this._item && this._item.setCssClass('item-datetime-disabled', this._disabled); + } + + /** + * @private + */ + writeValue(val: any) { + console.debug('datetime, writeValue', val); + this.setValue(val); + this.updateText(); + } + + /** + * @private + */ + ngAfterContentInit() { + // first see if locale names were provided in the inputs + // then check to see if they're in the config + // if neither were provided then it will use default English names + ['monthNames', 'monthShortNames', 'dayNames', 'dayShortNames'].forEach(type => { + this._locale[type] = convertToArrayOfStrings(isPresent(this[type]) ? this[type] : this._config.get(type), type); + }); + + // update how the datetime value is displayed as formatted text + this.updateText(); + } + + /** + * @private + */ + registerOnChange(fn: Function): void { + this._fn = fn; + this.onChange = (val: any) => { + console.debug('datetime, onChange', val); + this.setValue(val); + this.updateText(); + + // convert DateTimeData value to iso datetime format + fn(convertDataToISO(this._value)); + + this.onTouched(); + }; + } + + /** + * @private + */ + registerOnTouched(fn) { this.onTouched = fn; } + + /** + * @private + */ + onChange(val: any) { + // onChange used when there is not an ngControl + console.debug('datetime, onChange w/out ngControl', val); + this.setValue(val); + this.updateText(); + this.onTouched(); + } + + /** + * @private + */ + onTouched() { } + + /** + * @private + */ + ngOnDestroy() { + this._form.deregister(this); + } +} + +/** + * @private + * Use to convert a string of comma separated numbers or + * an array of numbers, and clean up any user input + */ +function convertToArrayOfNumbers(input: any, type: string): number[] { + var values: number[] = []; + + if (isString(input)) { + // convert the string to an array of strings + // auto remove any whitespace and [] characters + input = input.replace(/\[|\]|\s/g, '').split(','); + } + + if (isArray(input)) { + // ensure each value is an actual number in the returned array + input.forEach(num => { + num = parseInt(num, 10); + if (!isNaN(num)) { + values.push(num); + } + }); + } + + if (!values.length) { + console.warn(`Invalid "${type}Values". Must be an array of numbers, or a comma separated string of numbers.`); + } + + return values; +} + +/** + * @private + * Use to convert a string of comma separated strings or + * an array of strings, and clean up any user input + */ +function convertToArrayOfStrings(input: any, type: string): string[] { + if (isPresent(input)) { + var values: string[] = []; + + if (isString(input)) { + // convert the string to an array of strings + // auto remove any [] characters + input = input.replace(/\[|\]/g, '').split(','); + } + + if (isArray(input)) { + // trim up each string value + input.forEach(val => { + val = val.trim(); + if (val) { + values.push(val); + } + }); + } + + if (!values.length) { + console.warn(`Invalid "${type}Names". Must be an array of strings, or a comma separated string.`); + } + + return values; + } +} diff --git a/ionic/components/datetime/test/basic/e2e.ts b/ionic/components/datetime/test/basic/e2e.ts new file mode 100644 index 0000000000..d572f477d2 --- /dev/null +++ b/ionic/components/datetime/test/basic/e2e.ts @@ -0,0 +1,8 @@ + +it('should open basic datetime picker', function() { + element(by.css('.e2eOpenMMDDYYYY')).click(); +}); + +it('should close with Done button click', function() { + element(by.css('.picker-button:last-child')).click(); +}); diff --git a/ionic/components/datetime/test/basic/index.ts b/ionic/components/datetime/test/basic/index.ts new file mode 100644 index 0000000000..474f203e30 --- /dev/null +++ b/ionic/components/datetime/test/basic/index.ts @@ -0,0 +1,41 @@ +import {App, Page} from '../../../../../ionic'; + + +@Page({ + templateUrl: 'main.html' +}) +class E2EPage { + wwwInvented = '1989'; + time = '13:47'; + netscapeReleased = '1994-12-15T13:47:20.789'; + operaReleased = '1995-04-15'; + firefoxReleased = '2002-09-23T15:03:46.789'; + webkitOpenSourced = '2005-06-17T11:06Z'; + chromeReleased = '2008-09-02'; + leapYearsSummerMonths = ''; + + leapYearsArray = [2020, 2016, 2008, 2004, 2000, 1996]; + + customShortDay = [ + 's\u00f8n', + 'man', + 'tir', + 'ons', + 'tor', + 'fre', + 'l\u00f8r' + ]; + +} + + +@App({ + template: '' +}) +class E2EApp { + root; + + constructor() { + this.root = E2EPage; + } +} diff --git a/ionic/components/datetime/test/basic/main.html b/ionic/components/datetime/test/basic/main.html new file mode 100644 index 0000000000..46969834a5 --- /dev/null +++ b/ionic/components/datetime/test/basic/main.html @@ -0,0 +1,68 @@ + + Datetime + + + + + + YYYY + + + + + MMMM YY + + + + + MMM DD, YYYY + + + + + DDD. MM/DD/YY (locale day) + + + + + D MMM YYYY H:mm + + + + + DDDD MMM D, YYYY + + + + + HH:mm + + + + + h:mm a + + + + + hh:mm A (15 min steps) + + + + + Leap years, summer months + + + + + + \ No newline at end of file diff --git a/ionic/components/datetime/test/datetime.spec.ts b/ionic/components/datetime/test/datetime.spec.ts new file mode 100644 index 0000000000..c8da46a3ce --- /dev/null +++ b/ionic/components/datetime/test/datetime.spec.ts @@ -0,0 +1,518 @@ +import {DateTime, Form, Picker, Config, NavController} from '../../../../ionic'; +import * as datetime from '../../../../ionic/util/datetime-util'; + +export function run() { + +describe('DateTime', () => { + + describe('validate', () => { + + it('should restrict January 1-14, 2000 from selection, then allow it, and restrict December 15-31, 2001', () => { + datetime.max = '2001-12-15'; + datetime.min = '2000-01-15'; + datetime.pickerFormat = 'MM DD YYYY'; + var picker = new Picker(); + datetime.generate(picker); + + var columns = picker.getColumns(); + columns[0].selectedIndex = 0; // January + columns[1].selectedIndex = 0; // January 1st + columns[2].selectedIndex = 1; // January 1st, 2000 + + datetime.validate(picker); + + expect(columns[1].options[0].disabled).toEqual(true); + expect(columns[1].options[13].disabled).toEqual(true); + expect(columns[1].options[14].disabled).toEqual(false); + + columns[0].selectedIndex = 11; // December + columns[2].selectedIndex = 0; // December 1st, 2001 + + datetime.validate(picker); + + expect(columns[0].options[11].disabled).toEqual(false); + + expect(columns[1].options[14].disabled).toEqual(false); + expect(columns[1].options[15].disabled).toEqual(true); + expect(columns[1].options[30].disabled).toEqual(true); + }); + + it('should restrict January 2000 from selection, then allow it, and restrict December 2010', () => { + datetime.max = '2010-11-15'; + datetime.min = '2000-02-15'; + datetime.pickerFormat = 'MM DD YYYY'; + var picker = new Picker(); + datetime.generate(picker); + + var columns = picker.getColumns(); + columns[0].selectedIndex = 1; // February + columns[1].selectedIndex = 0; // February 1st + columns[2].selectedIndex = columns[2].options.length - 1; // February 1st, 2000 + + datetime.validate(picker); + + expect(columns[0].options[0].disabled).toEqual(true); + expect(columns[0].options[1].disabled).toEqual(false); + expect(columns[0].options[11].disabled).toEqual(false); + + columns[2].selectedIndex = 0; // December 1st, 2010 + + datetime.validate(picker); + + expect(columns[0].options[0].disabled).toEqual(false); + expect(columns[0].options[10].disabled).toEqual(false); + expect(columns[0].options[11].disabled).toEqual(true); + }); + + it('should only show 31 valid days in the selected 31 day month, then reset for 28 day, then to 30', () => { + datetime.max = '2010-12-31'; + datetime.min = '2000-01-01'; + datetime.pickerFormat = 'MM DD YYYY'; + + var picker = new Picker(); + datetime.generate(picker); + + var columns = picker.getColumns(); + columns[0].selectedIndex = 0; // January + columns[1].selectedIndex = 0; // January 1st + columns[2].selectedIndex = 0; // January 1st, 2010 + + datetime.validate(picker); + + for (var i = 0; i < 31; i++) { + expect(columns[1].options[i].disabled).toEqual(false); + } + + columns[0].selectedIndex = 1; // February + datetime.validate(picker); + + for (var i = 0; i < 28; i++) { + expect(columns[1].options[i].disabled).toEqual(false); + } + expect(columns[1].options[28].disabled).toEqual(true); + expect(columns[1].options[29].disabled).toEqual(true); + expect(columns[1].options[30].disabled).toEqual(true); + + columns[0].selectedIndex = 3; // April + datetime.validate(picker); + + for (var i = 0; i < 30; i++) { + expect(columns[1].options[i].disabled).toEqual(false); + } + expect(columns[1].options[30].disabled).toEqual(true); + }); + + }); + + describe('generate', () => { + + it('should generate with custom locale short month names from input property', () => { + datetime.monthShortNames = customLocale.monthShortNames; + datetime.ngAfterContentInit(); + datetime.pickerFormat = 'MMM YYYY'; + datetime.setValue('1994-12-15T13:47:20.789Z'); + + var picker = new Picker(); + datetime.generate(picker); + var columns = picker.getColumns(); + + expect(columns.length).toEqual(2); + expect(columns[0].name).toEqual('month'); + expect(columns[0].options[0].value).toEqual(1); + expect(columns[0].options[0].text).toEqual('jan'); + }); + + it('should generate with custom locale full month names from input property', () => { + datetime.monthNames = customLocale.monthNames; + datetime.ngAfterContentInit(); + datetime.pickerFormat = 'MMMM YYYY'; + datetime.setValue('1994-12-15T13:47:20.789Z'); + + var picker = new Picker(); + datetime.generate(picker); + var columns = picker.getColumns(); + + expect(columns.length).toEqual(2); + expect(columns[0].name).toEqual('month'); + expect(columns[0].options[0].value).toEqual(1); + expect(columns[0].options[0].text).toEqual('janeiro'); + }); + + it('should replace a picker format with both a day name and a numeric day to use only the numeric day', () => { + datetime.pickerFormat = 'DDDD D M YYYY'; + datetime.setValue('1994-12-15T13:47:20.789Z'); + + var picker = new Picker(); + datetime.generate(picker); + var columns = picker.getColumns(); + + expect(columns.length).toEqual(3); + expect(columns[0].name).toEqual('day'); + expect(columns[0].options[0].value).toEqual(1); + expect(columns[0].options[0].text).toEqual('1'); + }); + + it('should replace a picker format with only a day name to use a numeric day instead', () => { + datetime.pickerFormat = 'DDDD M YYYY'; + datetime.setValue('1994-12-15T13:47:20.789Z'); + + var picker = new Picker(); + datetime.generate(picker); + var columns = picker.getColumns(); + + expect(columns.length).toEqual(3); + expect(columns[0].name).toEqual('day'); + expect(columns[0].options[0].value).toEqual(1); + expect(columns[0].options[0].text).toEqual('1'); + }); + + it('should generate MM DD YYYY pickerFormat with min/max', () => { + datetime.max = '2010-12-31'; + datetime.min = '2000-01-01'; + datetime.pickerFormat = 'MM DD YYYY'; + + var picker = new Picker(); + datetime.generate(picker); + var columns = picker.getColumns(); + + expect(columns.length).toEqual(3); + expect(columns[0].options.length).toEqual(12); + expect(columns[0].options[0].value).toEqual(1); + expect(columns[0].options[11].value).toEqual(12); + + expect(columns[1].options.length).toEqual(31); + expect(columns[1].options[0].value).toEqual(1); + expect(columns[1].options[30].value).toEqual(31); + + expect(columns[2].options.length).toEqual(11); + expect(columns[2].options[0].value).toEqual(2010); + expect(columns[2].options[10].value).toEqual(2000); + }); + + it('should generate YYYY pickerFormat with min/max', () => { + datetime.max = '2010-01-01'; + datetime.min = '2000-01-01'; + datetime.pickerFormat = 'YYYY'; + + var picker = new Picker(); + datetime.generate(picker); + var columns = picker.getColumns(); + + expect(columns.length).toEqual(1); + expect(columns[0].options.length).toEqual(11); + expect(columns[0].options[0].value).toEqual(2010); + expect(columns[0].options[10].value).toEqual(2000); + }); + + }); + + describe('calcMinMax', () => { + + it('should max date with no max input, but has yearValues input', () => { + datetime.yearValues = '2000,1996,1992'; + datetime.calcMinMax(); + expect(datetime._max.year).toEqual(2000); + expect(datetime._max.month).toEqual(12); + expect(datetime._max.day).toEqual(31); + expect(datetime._max.hour).toEqual(23); + expect(datetime._max.minute).toEqual(59); + expect(datetime._max.second).toEqual(59); + }); + + it('should min date with no min input, but has yearValues input', () => { + datetime.yearValues = '2000,1996,1992'; + datetime.calcMinMax(); + expect(datetime._min.year).toEqual(1992); + expect(datetime._min.month).toEqual(1); + expect(datetime._min.day).toEqual(1); + expect(datetime._min.hour).toEqual(0); + expect(datetime._min.minute).toEqual(0); + expect(datetime._min.second).toEqual(0); + }); + + it('should min date with only YYYY', () => { + datetime.min = '1994'; + datetime.calcMinMax(); + expect(datetime._min.year).toEqual(1994); + expect(datetime._min.month).toEqual(1); + expect(datetime._min.day).toEqual(1); + expect(datetime._min.hour).toEqual(0); + expect(datetime._min.minute).toEqual(0); + expect(datetime._min.second).toEqual(0); + }); + + it('should max date with only YYYY', () => { + datetime.max = '1994'; + datetime.calcMinMax(); + expect(datetime._max.year).toEqual(1994); + expect(datetime._max.month).toEqual(12); + expect(datetime._max.day).toEqual(31); + expect(datetime._max.hour).toEqual(23); + expect(datetime._max.minute).toEqual(59); + expect(datetime._max.second).toEqual(59); + }); + + it('should max date from max input string', () => { + datetime.max = '1994-12-15T13:47:20.789Z'; + datetime.calcMinMax(); + expect(datetime._max.year).toEqual(1994); + expect(datetime._max.month).toEqual(12); + expect(datetime._max.day).toEqual(15); + expect(datetime._max.hour).toEqual(13); + expect(datetime._max.minute).toEqual(47); + expect(datetime._max.second).toEqual(20); + expect(datetime._max.millisecond).toEqual(789); + }); + + it('should min date from max input string', () => { + datetime.min = '0123-01-05T00:05:00.009Z'; + datetime.calcMinMax(); + expect(datetime._min.year).toEqual(123); + expect(datetime._min.month).toEqual(1); + expect(datetime._min.day).toEqual(5); + expect(datetime._min.hour).toEqual(0); + expect(datetime._min.minute).toEqual(5); + expect(datetime._min.second).toEqual(0); + expect(datetime._min.millisecond).toEqual(9); + }); + + it('should default max date when not set', () => { + datetime.calcMinMax(); + expect(datetime._max.year).toEqual(new Date().getFullYear()); + expect(datetime._max.month).toEqual(12); + expect(datetime._max.day).toEqual(31); + expect(datetime._max.hour).toEqual(23); + expect(datetime._max.minute).toEqual(59); + expect(datetime._max.second).toEqual(59); + }); + + it('should default min date when not set', () => { + datetime.calcMinMax(); + expect(datetime._min.year).toEqual(new Date().getFullYear() - 100); + expect(datetime._min.month).toEqual(1); + expect(datetime._min.day).toEqual(1); + expect(datetime._min.hour).toEqual(0); + expect(datetime._min.minute).toEqual(0); + expect(datetime._min.second).toEqual(0); + }); + + }); + + describe('setValue', () => { + + it('should update existing time value with 12-hour PM DateTimeData value', () => { + var d = '13:47:20.789Z'; + datetime.setValue(d); + + var dateTimeData = { + hour: { + text: '12', + value: 12, + }, + minute: { + text: '09', + value: 9, + }, + ampm: { + text: 'pm', + value: 'pm', + }, + }; + datetime.setValue(dateTimeData); + + expect(datetime.getValue().hour).toEqual(12); + expect(datetime.getValue().minute).toEqual(9); + expect(datetime.getValue().second).toEqual(20); + + dateTimeData.hour.value = 1; + datetime.setValue(dateTimeData); + + expect(datetime.getValue().hour).toEqual(13); + expect(datetime.getValue().minute).toEqual(9); + expect(datetime.getValue().second).toEqual(20); + }); + + it('should update existing time value with 12-hour AM DateTimeData value', () => { + var d = '13:47:20.789Z'; + datetime.setValue(d); + + var dateTimeData = { + hour: { + text: '12', + value: 12, + }, + minute: { + text: '09', + value: 9, + }, + ampm: { + text: 'am', + value: 'am', + }, + }; + datetime.setValue(dateTimeData); + + expect(datetime.getValue().hour).toEqual(0); + expect(datetime.getValue().minute).toEqual(9); + expect(datetime.getValue().second).toEqual(20); + + dateTimeData.hour.value = 11; + datetime.setValue(dateTimeData); + + expect(datetime.getValue().hour).toEqual(11); + expect(datetime.getValue().minute).toEqual(9); + expect(datetime.getValue().second).toEqual(20); + }); + + it('should update existing time value with new DateTimeData value', () => { + var d = '13:47:20.789Z'; + datetime.setValue(d); + + expect(datetime.getValue().hour).toEqual(13); + expect(datetime.getValue().minute).toEqual(47); + expect(datetime.getValue().second).toEqual(20); + + var dateTimeData = { + hour: { + text: '15', + value: 15, + }, + minute: { + text: '09', + value: 9, + }, + }; + datetime.setValue(dateTimeData); + + expect(datetime.getValue().year).toEqual(null); + expect(datetime.getValue().month).toEqual(null); + expect(datetime.getValue().day).toEqual(null); + expect(datetime.getValue().hour).toEqual(15); + expect(datetime.getValue().minute).toEqual(9); + expect(datetime.getValue().second).toEqual(20); + }); + + it('should update existing DateTimeData value with new DateTimeData value', () => { + var d = '1994-12-15T13:47:20.789Z'; + datetime.setValue(d); + + expect(datetime.getValue().year).toEqual(1994); + + var dateTimeData = { + year: { + text: '1995', + value: 1995, + }, + month: { + text: 'December', + value: 12, + }, + day: { + text: '20', + value: 20 + }, + whatevaIDoWhatIWant: -99, + }; + datetime.setValue(dateTimeData); + + expect(datetime.getValue().year).toEqual(1995); + expect(datetime.getValue().month).toEqual(12); + expect(datetime.getValue().day).toEqual(20); + expect(datetime.getValue().hour).toEqual(13); + expect(datetime.getValue().minute).toEqual(47); + }); + + it('should parse a ISO date string with no existing DateTimeData value', () => { + var d = '1994-12-15T13:47:20.789Z'; + datetime.setValue(d); + expect(datetime.getValue().year).toEqual(1994); + expect(datetime.getValue().month).toEqual(12); + expect(datetime.getValue().day).toEqual(15); + }); + + it('should not parse a Date object', () => { + var d = new Date(1994, 11, 15); + datetime.setValue(d); + expect(datetime.getValue()).toEqual({}); + }); + + it('should not parse a value with bad data', () => { + var d = 'umm 1994 i think'; + datetime.setValue(d); + expect(datetime.getValue()).toEqual({}); + }); + + it('should not parse a value with blank value', () => { + datetime.setValue(null); + expect(datetime.getValue()).toEqual({}); + + datetime.setValue(undefined); + expect(datetime.getValue()).toEqual({}); + + datetime.setValue(''); + expect(datetime.getValue()).toEqual({}); + }); + + }); + + var datetime: DateTime; + + beforeEach(() => { + datetime = new DateTime(new Form(), new Config(), null, {}); + }); + + console.warn = function(){}; + + // pt-br + var customLocale: datetime.LocaleData = { + dayShort: [ + 'domingo', + 'segunda-feira', + 'ter\u00e7a-feira', + 'quarta-feira', + 'quinta-feira', + 'sexta-feira', + 's\u00e1bado' + ], + dayShortNames: [ + 'dom', + 'seg', + 'ter', + 'qua', + 'qui', + 'sex', + 's\u00e1b' + ], + monthNames: [ + 'janeiro', + 'fevereiro', + 'mar\u00e7o', + 'abril', + 'maio', + 'junho', + 'julho', + 'agosto', + 'setembro', + 'outubro', + 'novembro', + 'dezembro' + ], + monthShortNames: [ + 'jan', + 'fev', + 'mar', + 'abr', + 'mai', + 'jun', + 'jul', + 'ago', + 'set', + 'out', + 'nov', + 'dez' + ], + }; + +}); + +} diff --git a/ionic/components/item/item.ts b/ionic/components/item/item.ts index f5d0568c98..e55ad09097 100644 --- a/ionic/components/item/item.ts +++ b/ionic/components/item/item.ts @@ -48,7 +48,7 @@ import {Label} from '../label/label'; '' + '' + '' + - '' + + '' + '' + '' + '' + diff --git a/ionic/components/picker/picker.ios.scss b/ionic/components/picker/picker.ios.scss index 6e58e3811e..a254a7230b 100644 --- a/ionic/components/picker/picker.ios.scss +++ b/ionic/components/picker/picker.ios.scss @@ -15,11 +15,12 @@ $picker-ios-button-height: $picker-ios-toolbar-height !defau $picker-ios-button-text-color: $link-ios-color !default; $picker-ios-button-background-color: transparent !default; -$picker-ios-column-padding: 0 12px !default; +$picker-ios-column-padding: 0 4px !default; +$picker-ios-column-perspective: 1000px !default; -$picker-ios-option-padding: 0 10px !default; +$picker-ios-option-padding: 0 !default; $picker-ios-option-text-color: $list-ios-text-color !default; -$picker-ios-option-font-size: 22px !default; +$picker-ios-option-font-size: 20px !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; @@ -74,7 +75,7 @@ $picker-highlight-opacity: .8 !default; .picker-columns { height: $picker-ios-height - $picker-ios-toolbar-height; - perspective: 1800px; + perspective: $picker-ios-column-perspective; } .picker-col { @@ -101,8 +102,6 @@ $picker-highlight-opacity: .8 !default; margin: 0; padding: $picker-ios-option-padding; - width: calc(100% - 24px); - font-size: $picker-ios-option-font-size; line-height: $picker-ios-option-height; diff --git a/ionic/components/picker/picker.md.scss b/ionic/components/picker/picker.md.scss index 825c72d3ca..8c10eb7335 100644 --- a/ionic/components/picker/picker.md.scss +++ b/ionic/components/picker/picker.md.scss @@ -15,15 +15,15 @@ $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-column-padding: 0 8px !default; -$picker-md-option-padding: 0 10px !default; +$picker-md-option-padding: 0 !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-font-size: 22px !default; $picker-md-option-selected-color: $link-md-color !default; $picker-highlight-opacity: .8 !default; @@ -98,8 +98,6 @@ $picker-highlight-opacity: .8 !default; margin: 0; padding: $picker-md-option-padding; - width: calc(100% - 24px); - font-size: $picker-md-option-font-size; line-height: $picker-md-option-height; diff --git a/ionic/components/picker/picker.scss b/ionic/components/picker/picker.scss index 792f3698fe..2eaf0cbcc4 100644 --- a/ionic/components/picker/picker.scss +++ b/ionic/components/picker/picker.scss @@ -99,10 +99,25 @@ ion-picker-cmp { flex: 1; width: 100%; +} + +.picker-opt .button-inner { + display: block; + + overflow: hidden; - text-align: center; text-overflow: ellipsis; white-space: nowrap; + + transition: opacity 150ms ease-in-out; +} + +.picker-opt.picker-opt-disabled { + pointer-events: none; +} + +.picker-opt-disabled .button-inner { + opacity: 0; } .picker-opts-left .button-inner { diff --git a/ionic/components/picker/picker.ts b/ionic/components/picker/picker.ts index 0c0f2c9758..0d58c58ac6 100644 --- a/ionic/components/picker/picker.ts +++ b/ionic/components/picker/picker.ts @@ -1,12 +1,12 @@ -import {Component, ElementRef, Input, ViewChild, Renderer, HostListener, ViewEncapsulation} from 'angular2/core'; +import {Component, ElementRef, Input, Output, EventEmitter, ViewChildren, QueryList, 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, isString, isNumber} from '../../util/util'; +import {isPresent, isString, isNumber, clamp} from '../../util/util'; import {NavParams} from '../nav/nav-params'; import {ViewController} from '../nav/view-controller'; -import {nativeRaf, cancelRaf, CSS, pointerCoord} from '../../util/dom'; +import {raf, cancelRaf, CSS, pointerCoord} from '../../util/dom'; /** @@ -16,6 +16,8 @@ import {nativeRaf, cancelRaf, CSS, pointerCoord} from '../../util/dom'; */ export class Picker extends ViewController { + @Output() change: EventEmitter; + constructor(opts: PickerOptions = {}) { opts.columns = opts.columns || []; opts.buttons = opts.buttons || []; @@ -25,6 +27,8 @@ export class Picker extends ViewController { this.viewType = 'picker'; this.isOverlay = true; + this.change = new EventEmitter(); + // 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 @@ -54,6 +58,14 @@ export class Picker extends ViewController { this.data.columns.push(column); } + getColumns(): PickerColumn[] { + return this.data.columns; + } + + refresh() { + this.instance.refresh && this.instance.refresh(); + } + /** * @param {string} cssClass CSS class name to add to the picker's outer wrapper. */ @@ -76,7 +88,7 @@ export class Picker extends ViewController { template: '
{{col.prefix}}
' + '
' + - '' + '
' + @@ -91,7 +103,6 @@ export class Picker extends ViewController { '(mousedown)': 'pointerStart($event)', '(mousemove)': 'pointerMove($event)', '(body:mouseup)': 'pointerEnd($event)', - '(body:mouseout)': 'mouseOut($event)', } }) class PickerColumnCmp { @@ -106,8 +117,11 @@ class PickerColumnCmp { startY: number = null; rafId: number; bounceFrom: number; + minY: number; maxY: number; rotateFactor: number; + lastIndex: number; + @Output() change: EventEmitter = new EventEmitter(); constructor(config: Config) { this.rotateFactor = config.getNumber('pickerRotateFactor', 0); @@ -123,8 +137,7 @@ class PickerColumnCmp { this.optHeight = (colEle.firstElementChild ? colEle.firstElementChild.clientHeight : 0); // set the scroll position for the selected option - let selectedIndex = this.col.options.indexOf(this.col.selected); - this.setSelected(selectedIndex, 0); + this.setSelected(this.col.selectedIndex, 0); } pointerStart(ev) { @@ -145,7 +158,24 @@ class PickerColumnCmp { this.velocity = 0; this.pos.length = 0; this.pos.push(this.startY, Date.now()); - this.maxY = (this.optHeight * (this.col.options.length - 1)) * -1; + + let minY = this.col.options.length - 1; + let maxY = 0; + + for (var i = 0; i < this.col.options.length; i++) { + if (this.col.options[i].disabled) { + continue; + } + if (i < minY) { + minY = i; + } + if (i > maxY) { + maxY = i; + } + } + + this.minY = (minY * this.optHeight * -1); + this.maxY = (maxY * this.optHeight * -1); } pointerMove(ev) { @@ -163,21 +193,21 @@ class PickerColumnCmp { // update the scroll position relative to pointer start position var y = this.y + (currentY - this.startY); - if (y > 0) { + if (y > this.minY) { // 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); + y += Math.pow(this.maxY - y, 0.9); this.bounceFrom = y; } else { this.bounceFrom = 0; } - this.update(y, 0, false); + this.update(y, 0, false, false); } } @@ -190,11 +220,11 @@ class PickerColumnCmp { if (this.bounceFrom > 0) { // bounce back up - this.update(0, 100, true); + this.update(this.minY, 100, true, true); } else if (this.bounceFrom < 0) { // bounce back down - this.update(this.maxY, 100, true); + this.update(this.maxY, 100, true, true); } else if (this.startY !== null) { var endY = pointerCoord(ev).y; @@ -226,7 +256,7 @@ class PickerColumnCmp { ev.stopPropagation(); var y = this.y + (endY - this.startY); - this.update(y, 0, true); + this.update(y, 0, true, true); } } @@ -235,19 +265,13 @@ class PickerColumnCmp { this.decelerate(); } - mouseOut(ev) { - if (ev.target.classList.contains('picker-col')) { - this.pointerEnd(ev); - } - } - decelerate() { - var y = 0; + let y = 0; cancelRaf(this.rafId); if (isNaN(this.y) || !this.optHeight) { // fallback in case numbers get outta wack - this.update(y, 0, true); + this.update(y, 0, true, true); } else if (Math.abs(this.velocity) > 0) { // still decelerating @@ -258,9 +282,9 @@ class PickerColumnCmp { y = Math.round(this.y - this.velocity); - if (y > 0) { + if (y > this.minY) { // whoops, it's trying to scroll up farther than the options we have! - y = 0; + y = this.minY; this.velocity = 0; } else if (y < this.maxY) { @@ -271,11 +295,13 @@ class PickerColumnCmp { console.log(`decelerate y: ${y}, velocity: ${this.velocity}, optHeight: ${this.optHeight}`); - this.update(y, 0, true); + var notLockedIn = (y % this.optHeight !== 0 || Math.abs(this.velocity) > 1); - if (y % this.optHeight !== 0 || Math.abs(this.velocity) > 1) { + this.update(y, 0, true, !notLockedIn); + + if (notLockedIn) { // isn't locked in yet, keep decelerating until it is - this.rafId = nativeRaf(this.decelerate.bind(this)); + this.rafId = raf(this.decelerate.bind(this)); } } else if (this.y % this.optHeight !== 0) { @@ -307,22 +333,17 @@ class PickerColumnCmp { this.velocity = 0; // so what y position we're at - this.update(y, duration, true); + this.update(y, duration, true, true); } - update(y: number, duration: number, saveY: boolean) { + update(y: number, duration: number, saveY: boolean, emitChange: boolean) { // ensure we've got a good round number :) y = Math.round(y); - let selectedIndex = Math.abs(Math.round(y / this.optHeight)); + this.col.selectedIndex = Math.max(Math.abs(Math.round(y / this.optHeight)), 0); - 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]; + for (var i = 0; i < this.col.options.length; i++) { + var opt = this.col.options[i]; var optTop = (i * this.optHeight); var optOffset = (optTop + y); @@ -332,7 +353,7 @@ class PickerColumnCmp { var translateZ = 0; if (this.rotateFactor !== 0) { - translateX = 10; + translateX = 0; translateZ = 90; if (rotateX > 90 || rotateX < -90) { translateX = -9999; @@ -343,17 +364,50 @@ class PickerColumnCmp { 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'); - + opt._trans = `rotateX(${rotateX}deg) translate3d(${translateX}px,${translateY}px,${translateZ}px)`; + opt._dur = (duration > 0 ? duration + 'ms' : ''); } if (saveY) { this.y = y; } + + if (emitChange) { + if (this.lastIndex === undefined) { + // have not set a last index yet + this.lastIndex = this.col.selectedIndex; + + } else if (this.lastIndex !== this.col.selectedIndex) { + // new selected index has changed from the last index + // update the lastIndex and emit that it has changed + this.lastIndex = this.col.selectedIndex; + this.change.emit(this.col.options[this.col.selectedIndex]); + } + } + } + + refresh() { + let min = this.col.options.length - 1; + let max = 0; + + for (var i = 0; i < this.col.options.length; i++) { + var opt = this.col.options[i]; + if (!opt.disabled) { + if (i < min) { + min = i; + } + if (i > max) { + max = i; + } + } + } + + var selectedIndex = clamp(min, this.col.selectedIndex, max); + + if (selectedIndex !== this.col.selectedIndex) { + var y = (selectedIndex * this.optHeight) * -1; + this.update(y, 150, true, true); + } } isPrevented(ev) { @@ -390,7 +444,7 @@ class PickerColumnCmp { '' + '
' + '
' + - '
' + + '
' + '
' + '
' + '', @@ -401,6 +455,7 @@ class PickerColumnCmp { encapsulation: ViewEncapsulation.None, }) class PickerDisplayCmp { + @ViewChildren(PickerColumnCmp) private _cols: QueryList; private d: PickerOptions; private created: number; private lastClick: number; @@ -452,12 +507,13 @@ class PickerDisplayCmp { column.options = column.options.map(inputOpt => { let opt: PickerColumnOption = { text: '', - value: '' + value: '', + disabled: inputOpt.disabled, }; if (isPresent(inputOpt)) { if (isString(inputOpt) || isNumber(inputOpt)) { - opt.text = inputOpt; + opt.text = inputOpt.toString(); opt.value = inputOpt; } else { @@ -472,6 +528,18 @@ class PickerDisplayCmp { }); } + refresh() { + this._cols.forEach(column => { + column.refresh(); + }); + } + + private _colChange(selectedOption: PickerColumnOption) { + // one of the columns has changed its selected index + var picker = this._viewCtrl; + picker.change.emit(this.getSelected()); + } + @HostListener('body:keyup', ['$event']) private _keyUp(ev: KeyboardEvent) { if (this.isEnabled() && this._viewCtrl.isLast()) { @@ -518,7 +586,7 @@ class PickerDisplayCmp { 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 (button.handler(this.getSelected()) === false) { // if the return value of the handler is false then do not dismiss shouldDismiss = false; } @@ -538,17 +606,20 @@ class PickerDisplayCmp { } dismiss(role): Promise { - return this._viewCtrl.dismiss(this.getValues(), role); + return this._viewCtrl.dismiss(this.getSelected(), 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.selected ? col.selected.value : null; + getSelected(): any { + let selected = {}; + this.d.columns.forEach((col, index) => { + let selectedColumn = col.options[col.selectedIndex]; + selected[col.name] = { + text: selectedColumn ? selectedColumn.text : null, + value: selectedColumn ? selectedColumn.value : null, + columnIndex: index, + }; }); - return values; + return selected; } isEnabled() { @@ -566,10 +637,10 @@ export interface PickerOptions { export interface PickerColumn { name?: string; - selected?: PickerColumnOption; + selectedIndex?: number; prefix?: string; suffix?: string; - options: PickerColumnOption[]; + options?: PickerColumnOption[]; cssClass?: string; columnWidth?: string; prefixWidth?: string; @@ -578,8 +649,9 @@ export interface PickerColumn { } export interface PickerColumnOption { + text?: string; value?: any; - text?: any; + disabled?: boolean; } diff --git a/ionic/components/picker/picker.wp.scss b/ionic/components/picker/picker.wp.scss index 85fdb18cf4..a1603409bc 100644 --- a/ionic/components/picker/picker.wp.scss +++ b/ionic/components/picker/picker.wp.scss @@ -15,15 +15,15 @@ $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-column-padding: 0 4px !default; -$picker-wp-option-padding: 0 10px !default; +$picker-wp-option-padding: 0 !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-font-size: 22px !default; $picker-wp-option-selected-color: $link-wp-color !default; $picker-highlight-opacity: .8 !default; @@ -110,8 +110,6 @@ $picker-highlight-opacity: .8 !default; margin: 0; padding: $picker-wp-option-padding; - width: calc(100% - 24px); - font-size: $picker-wp-option-font-size; line-height: $picker-wp-option-height; diff --git a/ionic/components/select/select.ts b/ionic/components/select/select.ts index 0daf458bd0..6a816e65bf 100644 --- a/ionic/components/select/select.ts +++ b/ionic/components/select/select.ts @@ -138,8 +138,8 @@ export class Select { private _labelId: string; private _multi: boolean = false; private _options: QueryList