From 932d3ca62f3e3ef08acb065ce6ec46faa3811f96 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Wed, 16 Jun 2021 15:54:15 -0400 Subject: [PATCH] feat(datetime): add calendar picker (#23416) resolves #19423 BREAKING CHANGE: The `ion-datetime` component has been revamped to use a new calendar style. As a result, some APIs have been removed. See https://github.com/ionic-team/ionic-framework/blob/master/BREAKING.md for more details. --- BREAKING.md | 41 + angular/src/directives/proxies.ts | 4 +- core/api.txt | 28 +- core/package-lock.json | 4 +- core/src/components.d.ts | 110 +- core/src/components/button/readme.md | 5 + core/src/components/buttons/readme.md | 13 + .../components/datetime/datetime-interface.ts | 19 +- .../components/datetime/datetime-util.spec.ts | 280 --- core/src/components/datetime/datetime-util.ts | 671 ------- .../src/components/datetime/datetime.ios.scss | 379 +++- .../datetime/datetime.ios.vars.scss | 23 +- core/src/components/datetime/datetime.md.scss | 266 ++- .../components/datetime/datetime.md.vars.scss | 34 +- core/src/components/datetime/datetime.scss | 400 +++- core/src/components/datetime/datetime.tsx | 1688 ++++++++++++----- core/src/components/datetime/readme.md | 1122 +++++------ .../datetime/test/a11y/datetime.spec.ts | 30 - .../src/components/datetime/test/basic/e2e.ts | 77 - .../components/datetime/test/basic/index.html | 405 ++-- .../src/components/datetime/test/color/e2e.ts | 33 + .../components/datetime/test/color/index.html | 249 +++ .../datetime/test/comparison.spec.ts | 51 + .../src/components/datetime/test/data.spec.ts | 215 +++ .../components/datetime/test/datetime.spec.ts | 151 -- .../components/datetime/test/demo/index.html | 294 +++ .../components/datetime/test/format.spec.ts | 69 + .../components/datetime/test/helpers.spec.ts | 47 + .../components/datetime/test/locale/e2e.ts | 21 + .../datetime/test/locale/index.html | 61 + .../datetime/test/manipulation.spec.ts | 390 ++++ .../components/datetime/test/minmax/e2e.ts | 15 + .../datetime/test/minmax/index.html | 65 + .../components/datetime/test/parse.spec.ts | 23 + .../datetime/test/presentation/e2e.ts | 15 + .../datetime/test/presentation/index.html | 77 + .../datetime/test/standalone/e2e.ts | 27 - .../datetime/test/standalone/index.html | 17 - .../components/datetime/test/state.spec.ts | 75 + .../components/datetime/test/values/e2e.ts | 15 + .../datetime/test/values/index.html | 69 + core/src/components/datetime/usage/angular.md | 134 +- .../components/datetime/usage/javascript.md | 137 +- core/src/components/datetime/usage/react.md | 204 +- core/src/components/datetime/usage/stencil.md | 157 +- core/src/components/datetime/usage/vue.md | 192 +- .../components/datetime/utils/comparison.ts | 34 + core/src/components/datetime/utils/data.ts | 290 +++ core/src/components/datetime/utils/format.ts | 65 + core/src/components/datetime/utils/helpers.ts | 31 + .../components/datetime/utils/manipulation.ts | 305 +++ core/src/components/datetime/utils/parse.ts | 106 ++ core/src/components/datetime/utils/state.ts | 117 ++ core/src/components/item/readme.md | 2 + core/src/components/label/readme.md | 2 + core/src/components/modal/modal.ios.scss | 7 + core/src/components/segment-button/readme.md | 5 + core/src/components/segment/readme.md | 13 + core/src/components/segment/segment.tsx | 12 +- core/src/utils/focus-visible.ts | 37 +- packages/vue/src/proxies.ts | 14 +- packages/vue/test-app/cypress.json | 1 - .../vue/test-app/tests/e2e/specs/overlays.js | 4 +- 63 files changed, 6062 insertions(+), 3385 deletions(-) delete mode 100644 core/src/components/datetime/datetime-util.spec.ts delete mode 100644 core/src/components/datetime/datetime-util.ts delete mode 100644 core/src/components/datetime/test/a11y/datetime.spec.ts delete mode 100644 core/src/components/datetime/test/basic/e2e.ts create mode 100644 core/src/components/datetime/test/color/e2e.ts create mode 100644 core/src/components/datetime/test/color/index.html create mode 100644 core/src/components/datetime/test/comparison.spec.ts create mode 100644 core/src/components/datetime/test/data.spec.ts delete mode 100644 core/src/components/datetime/test/datetime.spec.ts create mode 100644 core/src/components/datetime/test/demo/index.html create mode 100644 core/src/components/datetime/test/format.spec.ts create mode 100644 core/src/components/datetime/test/helpers.spec.ts create mode 100644 core/src/components/datetime/test/locale/e2e.ts create mode 100644 core/src/components/datetime/test/locale/index.html create mode 100644 core/src/components/datetime/test/manipulation.spec.ts create mode 100644 core/src/components/datetime/test/minmax/e2e.ts create mode 100644 core/src/components/datetime/test/minmax/index.html create mode 100644 core/src/components/datetime/test/parse.spec.ts create mode 100644 core/src/components/datetime/test/presentation/e2e.ts create mode 100644 core/src/components/datetime/test/presentation/index.html delete mode 100644 core/src/components/datetime/test/standalone/e2e.ts delete mode 100644 core/src/components/datetime/test/standalone/index.html create mode 100644 core/src/components/datetime/test/state.spec.ts create mode 100644 core/src/components/datetime/test/values/e2e.ts create mode 100644 core/src/components/datetime/test/values/index.html create mode 100644 core/src/components/datetime/utils/comparison.ts create mode 100644 core/src/components/datetime/utils/data.ts create mode 100644 core/src/components/datetime/utils/format.ts create mode 100644 core/src/components/datetime/utils/helpers.ts create mode 100644 core/src/components/datetime/utils/manipulation.ts create mode 100644 core/src/components/datetime/utils/parse.ts create mode 100644 core/src/components/datetime/utils/state.ts diff --git a/BREAKING.md b/BREAKING.md index f7360fd741..df8678cc7e 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -13,6 +13,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver ## Version 6.x - [Components](#components) + * [Datetime](#datetime) * [Header](#header) * [Modal](#modal) * [Popover](#popover) @@ -31,6 +32,46 @@ This is a comprehensive list of the breaking changes introduced in the major ver ### Components +#### Datetime + +The `ion-datetime` component has undergone a complete rewrite and uses a new calendar style. As a result, some of the properties no longer apply and have been removed. + +- `ion-datetime` now displays the calendar inline by default, allowing for more flexibility in presentation. As a result, the `placeholder` property has been removed. Additionally, the `text` and `placeholder` Shadow Parts have been removed. + +- The `--padding-bottom`, `--padding-end`, `--padding-start`, `--padding-top`, and `--placeholder-color` CSS Variables have been removed since `ion-datetime` now displays inline by default. + +- The `displayFormat` and `displayTimezone` properties have been removed since `ion-datetime` now displays inline with a calendar picker. To parse the UTC string provided in the payload of the `ionChange` event, we recommend using a 3rd-party date library like [date-fns](https://date-fns.org/). Here is an example of how you can take the UTC string from `ion-datetime` and format it to whatever style you prefer: + +```typescript +import { format, parseISO } from 'date-fns'; + +/** + * This is provided in the event + * payload from the `ionChange` event. + */ +const dateFromIonDatetime = '2021-06-04T14:23:00-04:00'; +const formattedString = format(parseISO(dateFromIonDatetime), 'MMM d, yyyy'); + +console.log(formattedString); // Jun 4, 2021 +``` + +- The `pickerOptions` and `pickerFormat` properties have been removed since `ion-datetime` now uses a calendar style rather than a wheel picker style. + +- The `monthNames`, `monthShortNames`, `dayNames`, and `dayShortNames` properties have been removed. `ion-datetime` can now automatically format these values according to your devices locale thanks to the [Intl.DateTimeFormat API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat). If you wish to force a specific locale, you can use the new `locale` property: + +```html + +``` + +- The `open` method has been removed. To present the datetime in an overlay, you can pass it into an `ion-modal` or `ion-popover` component and call the `present` method on the overlay instance. Alternatively, you can use the `trigger` property on `ion-modal` or `ion-popover` to present the overlay on a button click: + +```html +Open Datetime Modal + + + +``` + #### Header When using a collapsible large title, the last toolbar in the header with `collapse="condense"` no longer has a border. This does not affect the toolbar when the large title is collapsed. diff --git a/angular/src/directives/proxies.ts b/angular/src/directives/proxies.ts index de592ce60b..5131507a1d 100644 --- a/angular/src/directives/proxies.ts +++ b/angular/src/directives/proxies.ts @@ -217,8 +217,8 @@ export class IonContent { } export declare interface IonDatetime extends Components.IonDatetime { } -@ProxyCmp({ inputs: ["cancelText", "dayNames", "dayShortNames", "dayValues", "disabled", "displayFormat", "displayTimezone", "doneText", "hourValues", "max", "min", "minuteValues", "mode", "monthNames", "monthShortNames", "monthValues", "name", "pickerFormat", "pickerOptions", "placeholder", "readonly", "value", "yearValues"], "methods": ["open"] }) -@Component({ selector: "ion-datetime", changeDetection: ChangeDetectionStrategy.OnPush, template: "", inputs: ["cancelText", "dayNames", "dayShortNames", "dayValues", "disabled", "displayFormat", "displayTimezone", "doneText", "hourValues", "max", "min", "minuteValues", "mode", "monthNames", "monthShortNames", "monthValues", "name", "pickerFormat", "pickerOptions", "placeholder", "readonly", "value", "yearValues"] }) +@ProxyCmp({ inputs: ["cancelText", "color", "dayValues", "disabled", "doneText", "hourValues", "locale", "max", "min", "minuteValues", "mode", "monthValues", "name", "presentation", "readonly", "showDefaultButtons", "showDefaultTitle", "value", "yearValues"], "methods": ["confirm", "reset", "cancel"] }) +@Component({ selector: "ion-datetime", changeDetection: ChangeDetectionStrategy.OnPush, template: "", inputs: ["cancelText", "color", "dayValues", "disabled", "doneText", "hourValues", "locale", "max", "min", "minuteValues", "mode", "monthValues", "name", "presentation", "readonly", "showDefaultButtons", "showDefaultTitle", "value", "yearValues"] }) export class IonDatetime { ionCancel!: EventEmitter; ionChange!: EventEmitter; diff --git a/core/api.txt b/core/api.txt index 5612dbc32e..c2c5f53f3e 100644 --- a/core/api.txt +++ b/core/api.txt @@ -336,40 +336,34 @@ ion-content,part,scroll ion-datetime,shadow ion-datetime,prop,cancelText,string,'Cancel',false,false -ion-datetime,prop,dayNames,string | string[] | undefined,undefined,false,false -ion-datetime,prop,dayShortNames,string | string[] | undefined,undefined,false,false +ion-datetime,prop,color,string | undefined,'primary',false,false ion-datetime,prop,dayValues,number | number[] | string | undefined,undefined,false,false ion-datetime,prop,disabled,boolean,false,false,false -ion-datetime,prop,displayFormat,string,'MMM D, YYYY',false,false -ion-datetime,prop,displayTimezone,string | undefined,undefined,false,false ion-datetime,prop,doneText,string,'Done',false,false ion-datetime,prop,hourValues,number | number[] | string | undefined,undefined,false,false +ion-datetime,prop,locale,string,'default',false,false ion-datetime,prop,max,string | undefined,undefined,false,false ion-datetime,prop,min,string | undefined,undefined,false,false ion-datetime,prop,minuteValues,number | number[] | string | undefined,undefined,false,false ion-datetime,prop,mode,"ios" | "md",undefined,false,false -ion-datetime,prop,monthNames,string | string[] | undefined,undefined,false,false -ion-datetime,prop,monthShortNames,string | string[] | undefined,undefined,false,false ion-datetime,prop,monthValues,number | number[] | string | undefined,undefined,false,false ion-datetime,prop,name,string,this.inputId,false,false -ion-datetime,prop,pickerFormat,string | undefined,undefined,false,false -ion-datetime,prop,pickerOptions,undefined | { columns?: PickerColumn[] | undefined; buttons?: PickerButton[] | undefined; cssClass?: string | string[] | undefined; showBackdrop?: boolean | undefined; backdropDismiss?: boolean | undefined; animated?: boolean | undefined; mode?: Mode | undefined; keyboardClose?: boolean | undefined; id?: string | undefined; enterAnimation?: AnimationBuilder | undefined; leaveAnimation?: AnimationBuilder | undefined; },undefined,false,false -ion-datetime,prop,placeholder,null | string | undefined,undefined,false,false +ion-datetime,prop,presentation,"date" | "date-time" | "time" | "time-date",'date-time',false,false ion-datetime,prop,readonly,boolean,false,false,false +ion-datetime,prop,showDefaultButtons,boolean,false,false,false +ion-datetime,prop,showDefaultTitle,boolean,false,false,false ion-datetime,prop,value,null | string | undefined,undefined,false,false ion-datetime,prop,yearValues,number | number[] | string | undefined,undefined,false,false -ion-datetime,method,open,open() => Promise +ion-datetime,method,cancel,cancel(closeOverlay?: boolean) => Promise +ion-datetime,method,confirm,confirm(closeOverlay?: boolean) => Promise +ion-datetime,method,reset,reset(value?: string | undefined) => Promise ion-datetime,event,ionBlur,void,true ion-datetime,event,ionCancel,void,true ion-datetime,event,ionChange,DatetimeChangeEventDetail,true ion-datetime,event,ionFocus,void,true -ion-datetime,css-prop,--padding-bottom -ion-datetime,css-prop,--padding-end -ion-datetime,css-prop,--padding-start -ion-datetime,css-prop,--padding-top -ion-datetime,css-prop,--placeholder-color -ion-datetime,part,placeholder -ion-datetime,part,text +ion-datetime,css-prop,--background +ion-datetime,css-prop,--background-rgb +ion-datetime,css-prop,--title-color ion-fab,shadow ion-fab,prop,activated,boolean,false,false,false diff --git a/core/package-lock.json b/core/package-lock.json index 477e25c2dd..65132ff8b3 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/core", - "version": "5.6.7", + "version": "5.7.0-dev.202106081605.0bc250e", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@ionic/core", - "version": "5.6.7", + "version": "5.7.0-dev.202106081605.0bc250e", "license": "MIT", "dependencies": { "@stencil/core": "^2.4.0", diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 951214ae83..8d52ae80af 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -5,7 +5,7 @@ * It contains typing information for all components that exist in this project. */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; -import { AccordionGroupChangeEventDetail, ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DatetimeOptions, DomRenderFn, FooterHeightFn, FrameworkDelegate, HeaderFn, HeaderHeightFn, InputChangeEventDetail, ItemHeightFn, ItemRenderFn, ItemReorderEventDetail, MenuChangeEventDetail, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerButton, PickerColumn, PopoverSize, PositionAlign, PositionReference, PositionSide, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeValue, RefresherEventDetail, RouteID, RouterDirection, RouterEventDetail, RouterOutletOptions, RouteWrite, ScrollBaseDetail, ScrollDetail, SearchbarChangeEventDetail, SegmentButtonLayout, SegmentChangeEventDetail, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, Side, SpinnerTypes, StyleEventDetail, SwipeGestureHandler, TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout, TextareaChangeEventDetail, TextFieldTypes, ToastButton, ToggleChangeEventDetail, TransitionDoneFn, TransitionInstruction, TriggerAction, ViewController } from "./interface"; +import { AccordionGroupChangeEventDetail, ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DomRenderFn, FooterHeightFn, FrameworkDelegate, HeaderFn, HeaderHeightFn, InputChangeEventDetail, ItemHeightFn, ItemRenderFn, ItemReorderEventDetail, MenuChangeEventDetail, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerButton, PickerColumn, PopoverSize, PositionAlign, PositionReference, PositionSide, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeValue, RefresherEventDetail, RouteID, RouterDirection, RouterEventDetail, RouterOutletOptions, RouteWrite, ScrollBaseDetail, ScrollDetail, SearchbarChangeEventDetail, SegmentButtonLayout, SegmentChangeEventDetail, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, Side, SpinnerTypes, StyleEventDetail, SwipeGestureHandler, TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout, TextareaChangeEventDetail, TextFieldTypes, ToastButton, ToggleChangeEventDetail, TransitionDoneFn, TransitionInstruction, TriggerAction, ViewController } from "./interface"; import { IonicSafeString } from "./utils/sanitization"; import { NavigationHookCallback } from "./components/route/route-interface"; import { SelectCompareFn } from "./components/select/select-interface"; @@ -623,18 +623,22 @@ export namespace Components { "scrollY": boolean; } interface IonDatetime { + /** + * Emits the ionCancel event and optionally closes the popover or modal that the datetime was presented in. + */ + "cancel": (closeOverlay?: boolean) => Promise; /** * The text to display on the picker's cancel button. */ "cancelText": string; /** - * Full day of the week names. This can be used to provide locale names for each day in the week. Defaults to English. + * The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). */ - "dayNames"?: string[] | string; + "color"?: Color; /** - * Short abbreviated day of the week names. This can be used to provide locale names for each day in the week. Defaults to English. Defaults to: `['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']` + * Confirms the selected datetime value, updates the `value` property, and optionally closes the popover or modal that the datetime was presented in. */ - "dayShortNames"?: string[] | string; + "confirm": (closeOverlay?: boolean) => Promise; /** * 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 a number, an array of numbers, or a 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. */ @@ -643,14 +647,6 @@ export namespace Components { * If `true`, the user cannot interact with the datetime. */ "disabled": boolean; - /** - * 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`. - */ - "displayFormat": string; - /** - * The timezone to use for display purposes only. See [Date.prototype.toLocaleString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleString) for a list of supported timezones. If no value is provided, the component will default to displaying times in the user's local timezone. - */ - "displayTimezone"?: string; /** * The text to display on the picker's "Done" button. */ @@ -659,6 +655,10 @@ export namespace Components { * Values used to create the list of selectable hours. By default the hour values range from `0` 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 a number, an array of numbers, or a string of comma separated numbers. */ "hourValues"?: number[] | number | string; + /** + * The locale to use for `ion-datetime`. This impacts month and day name formatting. The `'default'` value refers to the default locale set by your device. + */ + "locale": 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. */ @@ -675,14 +675,6 @@ export namespace Components { * The mode determines which platform styles to use. */ "mode"?: "ios" | "md"; - /** - * Full names for each month name. This can be used to provide locale month names. Defaults to English. - */ - "monthNames"?: string[] | string; - /** - * Short abbreviated names for each month name. This can be used to provide locale month names. Defaults to English. - */ - "monthShortNames"?: string[] | 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 a number, an array of numbers, or a 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`. */ @@ -692,25 +684,25 @@ export namespace Components { */ "name": string; /** - * Opens the datetime overlay. + * Which values you want to select. `'date'` will show a calendar picker to select the month, day, and year. `'time'` will show a time picker to select the hour, minute, and (optionally) AM/PM. `'date-time'` will show the date picker first and time picker second. `'time-date'` will show the time picker first and date picker second. */ - "open": () => Promise; - /** - * 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`. - */ - "pickerFormat"?: string; - /** - * Any additional options that the picker interface can accept. See the [Picker API docs](../picker) for the picker options. - */ - "pickerOptions"?: DatetimeOptions; - /** - * The text to display when there's no date selected yet. Using lowercase to match the input attribute - */ - "placeholder"?: string | null; + "presentation": 'date-time' | 'time-date' | 'date' | 'time'; /** * If `true`, the datetime appears normal but is not interactive. */ "readonly": boolean; + /** + * Resets the internal state of the datetime but does not update the value. Passing a value ISO-8601 string will reset the state of te component to the provided date. + */ + "reset": (value?: string | undefined) => Promise; + /** + * If `true`, the default "Cancel" and "OK" buttons will be rendered at the bottom of the `ion-datetime` component. Developers can also use the `button` slot if they want to customize these buttons. If custom buttons are set in the `button` slot then the default buttons will not be rendered. + */ + "showDefaultButtons": boolean; + /** + * If `true`, a header will be shown above the calendar picker. On `ios` mode this will include the slotted title, and on `md` mode this will include the slotted title and the selected date. + */ + "showDefaultTitle": boolean; /** * The value of the datetime as a valid ISO 8601 datetime string. */ @@ -4067,13 +4059,9 @@ declare namespace LocalJSX { */ "cancelText"?: string; /** - * Full day of the week names. This can be used to provide locale names for each day in the week. Defaults to English. + * The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). */ - "dayNames"?: string[] | string; - /** - * Short abbreviated day of the week names. This can be used to provide locale names for each day in the week. Defaults to English. Defaults to: `['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']` - */ - "dayShortNames"?: string[] | string; + "color"?: Color; /** * 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 a number, an array of numbers, or a 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. */ @@ -4082,14 +4070,6 @@ declare namespace LocalJSX { * If `true`, the user cannot interact with the datetime. */ "disabled"?: boolean; - /** - * 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`. - */ - "displayFormat"?: string; - /** - * The timezone to use for display purposes only. See [Date.prototype.toLocaleString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleString) for a list of supported timezones. If no value is provided, the component will default to displaying times in the user's local timezone. - */ - "displayTimezone"?: string; /** * The text to display on the picker's "Done" button. */ @@ -4098,6 +4078,10 @@ declare namespace LocalJSX { * Values used to create the list of selectable hours. By default the hour values range from `0` 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 a number, an array of numbers, or a string of comma separated numbers. */ "hourValues"?: number[] | number | string; + /** + * The locale to use for `ion-datetime`. This impacts month and day name formatting. The `'default'` value refers to the default locale set by your device. + */ + "locale"?: 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. */ @@ -4114,14 +4098,6 @@ declare namespace LocalJSX { * The mode determines which platform styles to use. */ "mode"?: "ios" | "md"; - /** - * Full names for each month name. This can be used to provide locale month names. Defaults to English. - */ - "monthNames"?: string[] | string; - /** - * Short abbreviated names for each month name. This can be used to provide locale month names. Defaults to English. - */ - "monthShortNames"?: string[] | 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 a number, an array of numbers, or a 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`. */ @@ -4151,21 +4127,21 @@ declare namespace LocalJSX { */ "onIonStyle"?: (event: CustomEvent) => void; /** - * 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`. + * Which values you want to select. `'date'` will show a calendar picker to select the month, day, and year. `'time'` will show a time picker to select the hour, minute, and (optionally) AM/PM. `'date-time'` will show the date picker first and time picker second. `'time-date'` will show the time picker first and date picker second. */ - "pickerFormat"?: string; - /** - * Any additional options that the picker interface can accept. See the [Picker API docs](../picker) for the picker options. - */ - "pickerOptions"?: DatetimeOptions; - /** - * The text to display when there's no date selected yet. Using lowercase to match the input attribute - */ - "placeholder"?: string | null; + "presentation"?: 'date-time' | 'time-date' | 'date' | 'time'; /** * If `true`, the datetime appears normal but is not interactive. */ "readonly"?: boolean; + /** + * If `true`, the default "Cancel" and "OK" buttons will be rendered at the bottom of the `ion-datetime` component. Developers can also use the `button` slot if they want to customize these buttons. If custom buttons are set in the `button` slot then the default buttons will not be rendered. + */ + "showDefaultButtons"?: boolean; + /** + * If `true`, a header will be shown above the calendar picker. On `ios` mode this will include the slotted title, and on `md` mode this will include the slotted title and the selected date. + */ + "showDefaultTitle"?: boolean; /** * The value of the datetime as a valid ISO 8601 datetime string. */ diff --git a/core/src/components/button/readme.md b/core/src/components/button/readme.md index 772704f3d9..c280c8838a 100644 --- a/core/src/components/button/readme.md +++ b/core/src/components/button/readme.md @@ -365,6 +365,10 @@ export default defineComponent({ ## Dependencies +### Used by + + - [ion-datetime](../datetime) + ### Depends on - [ion-ripple-effect](../ripple-effect) @@ -373,6 +377,7 @@ export default defineComponent({ ```mermaid graph TD; ion-button --> ion-ripple-effect + ion-datetime --> ion-button style ion-button fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/core/src/components/buttons/readme.md b/core/src/components/buttons/readme.md index 3956015b23..8fa4b51a63 100644 --- a/core/src/components/buttons/readme.md +++ b/core/src/components/buttons/readme.md @@ -322,6 +322,19 @@ export default defineComponent({ | `collapse` | `collapse` | If true, buttons will disappear when its parent toolbar has fully collapsed if the toolbar is not the first toolbar. If the toolbar is the first toolbar, the buttons will be hidden and will only be shown once all toolbars have fully collapsed. Only applies in `ios` mode with `collapse` set to `true` on `ion-header`. Typically used for [Collapsible Large Titles](https://ionicframework.com/docs/api/title#collapsible-large-titles) | `boolean` | `false` | +## Dependencies + +### Used by + + - [ion-datetime](../datetime) + +### Graph +```mermaid +graph TD; + ion-datetime --> ion-buttons + style ion-buttons fill:#f9f,stroke:#333,stroke-width:4px +``` + ---------------------------------------------- *Built with [StencilJS](https://stenciljs.com/)* diff --git a/core/src/components/datetime/datetime-interface.ts b/core/src/components/datetime/datetime-interface.ts index f803485d4f..1f0f64221e 100644 --- a/core/src/components/datetime/datetime-interface.ts +++ b/core/src/components/datetime/datetime-interface.ts @@ -1,7 +1,18 @@ -import { PickerOptions } from '../../interface'; - -export type DatetimeOptions = Partial; +export interface DatetimeOptions { + tmp?: string; +} export interface DatetimeChangeEventDetail { - value: string | undefined | null; + value?: string | null; +} + +export interface DatetimeParts { + month: number; + day: number | null; + year: number; + dayOfWeek?: number | null; + hour?: number; + minute?: number; + ampm?: 'am' | 'pm'; + tzOffset?: number; } diff --git a/core/src/components/datetime/datetime-util.spec.ts b/core/src/components/datetime/datetime-util.spec.ts deleted file mode 100644 index 589b1b0bc9..0000000000 --- a/core/src/components/datetime/datetime-util.spec.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { convertDataToISO, parseDate } from './datetime-util'; - -describe('datetime-util', () => { - describe('convertDataToISO', () => { - it('prints an empty string for an empty datetime', () => { - expect(convertDataToISO({})).toEqual(''); - }); - - describe('date', () => { - it('prints the year', () => { - expect(convertDataToISO({ year: 2018 })).toEqual('2018'); - }); - - it('pads out the year', () => { - expect(convertDataToISO({ year: 1 })).toEqual('0001'); - }); - - it('prints the month', () => { - expect(convertDataToISO({ year: 2018, month: 12 })).toEqual('2018-12'); - }); - - it('pads the month', () => { - expect(convertDataToISO({ year: 2018, month: 3 })).toEqual('2018-03'); - }); - - it('prints the day', () => { - expect(convertDataToISO({ year: 2018, month: 12, day: 25 })).toEqual( - '2018-12-25' - ); - }); - - it('pads the day', () => { - expect(convertDataToISO({ year: 2018, month: 3, day: 13 })).toEqual( - '2018-03-13' - ); - }); - }); - - describe('time', () => { - it('prints the hour and minute', () => { - expect(convertDataToISO({ hour: 15, minute: 32 })).toEqual('15:32'); - }); - - it('pads the hour and minute', () => { - expect(convertDataToISO({ hour: 3, minute: 4 })).toEqual('03:04'); - }); - - it('prints seconds', () => { - expect(convertDataToISO({ hour: 15, minute: 32, second: 42 })).toEqual('15:32:42'); - }); - - it('pads seconds', () => { - expect(convertDataToISO({ hour: 15, minute: 32, second: 2 })).toEqual('15:32:02'); - }); - - it('prints milliseconds', () => { - expect(convertDataToISO({ hour: 15, minute: 32, second:42, millisecond: 143 })).toEqual('15:32:42.143'); - }); - - it('pads milliseconds', () => { - expect(convertDataToISO({ hour: 15, minute: 32, second:42, millisecond: 7 })).toEqual('15:32:42.007'); - }); - }); - - describe('date-time', () => { - it('prints the hours and minutes', () => { - expect( - convertDataToISO({ - year: 2018, - month: 12, - day: 25, - hour: 14, - minute: 42 - }) - ).toEqual('2018-12-25T14:42:00Z'); - }); - - it('pads the hours and minutes', () => { - expect( - convertDataToISO({ - year: 2018, - month: 12, - day: 25, - hour: 0, - minute: 2 - }) - ).toEqual('2018-12-25T00:02:00Z'); - }); - - it('prints the seconds', () => { - expect( - convertDataToISO({ - year: 2018, - month: 12, - day: 25, - hour: 14, - minute: 42, - second: 36 - }) - ).toEqual('2018-12-25T14:42:36Z'); - }); - - it('pads the seconds', () => { - expect( - convertDataToISO({ - year: 2018, - month: 12, - day: 25, - hour: 14, - minute: 42, - second: 3 - }) - ).toEqual('2018-12-25T14:42:03Z'); - }); - - it('prints the milliseconds', () => { - expect( - convertDataToISO({ - year: 2018, - month: 12, - day: 25, - hour: 14, - minute: 42, - second: 23, - millisecond: 250 - }) - ).toEqual('2018-12-25T14:42:23.250Z'); - }); - - it('pads the milliseconds', () => { - expect( - convertDataToISO({ - year: 2018, - month: 12, - day: 25, - hour: 14, - minute: 42, - second: 23, - millisecond: 25 - }) - ).toEqual('2018-12-25T14:42:23.025Z'); - }); - - it('appends a whole hour positive offset timezone', () => { - expect( - convertDataToISO({ - year: 2018, - month: 12, - day: 25, - hour: 14, - minute: 42, - tzOffset: 360 - }) - ).toEqual('2018-12-25T14:42:00+06:00'); - }); - - it('appends a partial hour positive offset timezone', () => { - expect( - convertDataToISO({ - year: 2018, - month: 12, - day: 25, - hour: 14, - minute: 42, - tzOffset: 390 - }) - ).toEqual('2018-12-25T14:42:00+06:30'); - }); - - it('appends a whole hour negative offset timezone', () => { - expect( - convertDataToISO({ - year: 2018, - month: 12, - day: 25, - hour: 14, - minute: 42, - tzOffset: -300 - }) - ).toEqual('2018-12-25T14:42:00-05:00'); - }); - - it('appends a partial hour negative offset timezone', () => { - expect( - convertDataToISO({ - year: 2018, - month: 12, - day: 25, - hour: 14, - minute: 42, - tzOffset: -435 - }) - ).toEqual('2018-12-25T14:42:00-07:15'); - }); - - it('appends a zero offset timezone', () => { - expect( - convertDataToISO({ - year: 2018, - month: 12, - day: 25, - hour: 14, - minute: 42, - tzOffset: 0 - }) - ).toEqual('2018-12-25T14:42:00-00:00'); - }); - }); - }); - - describe('parseDate', () => { - it('should parse a single year', () => { - const date = parseDate('1000'); - expect(date).toEqual({ - "day": undefined, - "hour": undefined, - "millisecond": undefined, - "minute": undefined, - "month": undefined, - "second": undefined, - "tzOffset": 0, - "year": 1000, - "ampm": undefined - }); - }); - - it('should parse a time', () => { - const date = parseDate('12:20'); - expect(date).toEqual({ - "day": undefined, - "hour": 12, - "millisecond": undefined, - "minute": 20, - "month": undefined, - "second": undefined, - "tzOffset": 0, - "year": undefined, - "ampm": undefined - }); - }); - - it('should parse a full ISO date', () => { - const date = parseDate('1994-12-15T13:47:20Z'); - expect(date).toEqual({ - "day": 15, - "hour": 13, - "millisecond": undefined, - "minute": 47, - "month": 12, - "second": 20, - "tzOffset": 0, - "year": 1994, - "ampm": undefined - }); - }); - - it('should parse a partial ISO date', () => { - const date = parseDate('2018-01-02'); - expect(date).toEqual({ - "day": 2, - "hour": undefined, - "millisecond": undefined, - "minute": undefined, - "month": 1, - "second": undefined, - "tzOffset": 0, - "year": 2018, - "ampm": undefined - }); - }); - - - it('should return undefined', () => { - expect(parseDate(null)).toBeUndefined(); - expect(parseDate(undefined)).toBeUndefined(); - expect(parseDate('')).toBeUndefined(); - expect(parseDate('3432-12-12-234')).toBeUndefined(); - }); - }); -}); diff --git a/core/src/components/datetime/datetime-util.ts b/core/src/components/datetime/datetime-util.ts deleted file mode 100644 index bce769f58e..0000000000 --- a/core/src/components/datetime/datetime-util.ts +++ /dev/null @@ -1,671 +0,0 @@ -/** - * Gets a date value given a format - * Defaults to the current date if - * no date given - */ -export const getDateValue = (date: DatetimeData, format: string): number | string => { - const getValue = getValueFromFormat(date, format); - - if (getValue !== undefined) { - if (format === FORMAT_A || format === FORMAT_a) { - date.ampm = getValue; - } - - return getValue; - } - - const defaultDate = parseDate(new Date().toISOString()); - return getValueFromFormat((defaultDate as DatetimeData), format); -}; - -export const renderDatetime = (template: string, value: DatetimeData | undefined, locale: LocaleData): string | undefined => { - if (value === undefined) { - return undefined; - } - - const tokens: string[] = []; - let hasText = false; - FORMAT_KEYS.forEach((format, index) => { - if (template.indexOf(format.f) > -1) { - const token = '{' + index + '}'; - const text = renderTextFormat(format.f, (value as any)[format.k], value, locale); - - if (!hasText && text !== undefined && (value as any)[format.k] != null) { - hasText = true; - } - - tokens.push(token, text || ''); - - template = template.replace(format.f, token); - } - }); - - if (!hasText) { - return undefined; - } - - for (let i = 0; i < tokens.length; i += 2) { - template = template.replace(tokens[i], tokens[i + 1]); - } - - return template; -}; - -export const renderTextFormat = (format: string, value: any, date: DatetimeData | undefined, locale: LocaleData): string | undefined => { - if ((format === FORMAT_DDDD || format === FORMAT_DDD)) { - try { - value = (new Date(date!.year!, date!.month! - 1, date!.day)).getDay(); - - if (format === FORMAT_DDDD) { - return (locale.dayNames ? locale.dayNames : DAY_NAMES)[value]; - } - - return (locale.dayShortNames ? locale.dayShortNames : DAY_SHORT_NAMES)[value]; - - } catch (e) { - // ignore - } - - return undefined; - } - - if (format === FORMAT_A) { - return date !== undefined && date.hour !== undefined - ? (date.hour < 12 ? 'AM' : 'PM') - : value ? value.toUpperCase() : ''; - } - - if (format === FORMAT_a) { - return date !== undefined && date.hour !== undefined - ? (date.hour < 12 ? 'am' : 'pm') - : value || ''; - } - - if (value == null) { - return ''; - } - - if (format === FORMAT_YY || format === FORMAT_MM || - format === FORMAT_DD || format === FORMAT_HH || - format === FORMAT_mm || format === FORMAT_ss) { - return twoDigit(value); - } - - if (format === FORMAT_YYYY) { - return fourDigit(value); - } - - if (format === FORMAT_MMMM) { - return (locale.monthNames ? locale.monthNames : MONTH_NAMES)[value - 1]; - } - - if (format === FORMAT_MMM) { - return (locale.monthShortNames ? locale.monthShortNames : MONTH_SHORT_NAMES)[value - 1]; - } - - if (format === FORMAT_hh || format === FORMAT_h) { - if (value === 0) { - return '12'; - } - if (value > 12) { - value -= 12; - } - if (format === FORMAT_hh && value < 10) { - return ('0' + value); - } - } - - return value.toString(); -}; - -export const dateValueRange = (format: string, min: DatetimeData, max: DatetimeData): any[] => { - const opts: any[] = []; - - if (format === FORMAT_YYYY || format === FORMAT_YY) { - // year - if (max.year === undefined || min.year === undefined) { - throw new Error('min and max year is undefined'); - } - - for (let i = max.year; i >= min.year; i--) { - opts.push(i); - } - - } else if (format === FORMAT_MMMM || format === FORMAT_MMM || - format === FORMAT_MM || format === FORMAT_M || - format === FORMAT_hh || format === FORMAT_h) { - - // month or 12-hour - for (let i = 1; i < 13; i++) { - opts.push(i); - } - - } else if (format === FORMAT_DDDD || format === FORMAT_DDD || - format === FORMAT_DD || format === FORMAT_D) { - // day - for (let i = 1; i < 32; i++) { - opts.push(i); - } - - } else if (format === FORMAT_HH || format === FORMAT_H) { - // 24-hour - for (let i = 0; i < 24; i++) { - opts.push(i); - } - - } else if (format === FORMAT_mm || format === FORMAT_m) { - // minutes - for (let i = 0; i < 60; i++) { - opts.push(i); - } - - } else if (format === FORMAT_ss || format === FORMAT_s) { - // seconds - for (let i = 0; i < 60; i++) { - opts.push(i); - } - - } else if (format === FORMAT_A || format === FORMAT_a) { - // AM/PM - opts.push('am', 'pm'); - } - - return opts; -}; - -export const dateSortValue = (year: number | undefined, month: number | undefined, day: number | undefined, hour = 0, minute = 0): number => { - return parseInt(`1${fourDigit(year)}${twoDigit(month)}${twoDigit(day)}${twoDigit(hour)}${twoDigit(minute)}`, 10); -}; - -export const dateDataSortValue = (data: DatetimeData): number => { - return dateSortValue(data.year, data.month, data.day, data.hour, data.minute); -}; - -export const daysInMonth = (month: number, year: number): number => { - return (month === 4 || month === 6 || month === 9 || month === 11) ? 30 : (month === 2) ? isLeapYear(year) ? 29 : 28 : 31; -}; - -export const isLeapYear = (year: number): boolean => { - return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); -}; - -const ISO_8601_REGEXP = /^(\d{4}|[+\-]\d{6})(?:-(\d{2})(?:-(\d{2}))?)?(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{3}))?)?(?:(Z)|([+\-])(\d{2})(?::(\d{2}))?)?)?$/; -const TIME_REGEXP = /^((\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{3}))?)?(?:(Z)|([+\-])(\d{2})(?::(\d{2}))?)?)?$/; - -export const parseDate = (val: string | undefined | null): DatetimeData | undefined => { - // manually parse IS0 cuz Date.parse cannot be trusted - // ISO 8601 format: 1994-12-15T13:47:20Z - let parse: any[] | null = null; - - if (val != null && val !== '') { - // try parsing for just time first, HH:MM - parse = TIME_REGEXP.exec(val); - if (parse) { - // adjust the array so it fits nicely with the datetime parse - parse.unshift(undefined, undefined); - parse[2] = parse[3] = undefined; - - } else { - // try parsing for full ISO datetime - parse = ISO_8601_REGEXP.exec(val); - } - } - - if (parse === null) { - // wasn't able to parse the ISO datetime - return undefined; - } - - // ensure all the parse values exist with at least 0 - for (let i = 1; i < 8; i++) { - parse[i] = parse[i] !== undefined ? parseInt(parse[i], 10) : undefined; - } - - let tzOffset = 0; - if (parse[9] && parse[10]) { - // hours - tzOffset = parseInt(parse[10], 10) * 60; - if (parse[11]) { - // minutes - tzOffset += parseInt(parse[11], 10); - } - if (parse[9] === '-') { - // + or - - tzOffset *= -1; - } - } - - return { - year: parse[1], - month: parse[2], - day: parse[3], - hour: parse[4], - minute: parse[5], - second: parse[6], - millisecond: parse[7], - tzOffset, - }; -}; - -/** - * Converts a valid UTC datetime string to JS Date time object. - * By default uses the users local timezone, but an optional - * timezone can be provided. - * Note: This is not meant for time strings - * such as "01:47" - */ -export const getDateTime = (dateString: any = '', timeZone: any = ''): Date => { - /** - * If user passed in undefined - * or null, convert it to the - * empty string since the rest - * of this functions expects - * a string - */ - if (dateString === undefined || dateString === null) { - dateString = ''; - } - - /** - * Ensures that YYYY-MM-DD, YYYY-MM, - * YYYY-DD, YYYY, etc does not get affected - * by timezones and stays on the day/month - * that the user provided - */ - if ( - dateString.length === 10 || - dateString.length === 7 || - dateString.length === 4 - ) { - dateString += ' '; - } - - const date = (typeof dateString === 'string' && dateString.length > 0) ? new Date(dateString) : new Date(); - const localDateTime = new Date( - Date.UTC( - date.getFullYear(), - date.getMonth(), - date.getDate(), - date.getHours(), - date.getMinutes(), - date.getSeconds(), - date.getMilliseconds() - ) - ); - - if (timeZone && timeZone.length > 0) { - return new Date(date.getTime() - getTimezoneOffset(localDateTime, timeZone)); - } - - return localDateTime; -}; - -export const getTimezoneOffset = (localDate: Date, timeZone: string) => { - const utcDateTime = new Date(localDate.toLocaleString('en-US', { timeZone: 'utc' })); - const tzDateTime = new Date(localDate.toLocaleString('en-US', { timeZone })); - return utcDateTime.getTime() - tzDateTime.getTime(); -}; - -export const updateDate = (existingData: DatetimeData, newData: any, displayTimezone?: string): boolean => { - - if (!newData || typeof newData === 'string') { - const dateTime = getDateTime(newData, displayTimezone); - if (!Number.isNaN(dateTime.getTime())) { - newData = dateTime.toISOString(); - } - } - - if (newData && newData !== '') { - - if (typeof newData === 'string') { - // new date is a string, and hopefully in the ISO format - // convert it to our DatetimeData if a valid ISO - newData = parseDate(newData); - if (newData) { - // successfully parsed the ISO string to our DatetimeData - Object.assign(existingData, newData); - return true; - } - - } else if ((newData.year || newData.hour || newData.month || newData.day || newData.minute || newData.second)) { - // newData is from the datetime picker's selected values - // update the existing datetimeValue with the new values - if (newData.ampm !== undefined && newData.hour !== undefined) { - // change the value of the hour based on whether or not it is am or pm - // if the meridiem is pm and equal to 12, it remains 12 - // otherwise we add 12 to the hour value - // if the meridiem is am and equal to 12, we change it to 0 - // otherwise we use its current hour value - // for example: 8 pm becomes 20, 12 am becomes 0, 4 am becomes 4 - newData.hour.value = (newData.ampm.value === 'pm') - ? (newData.hour.value === 12 ? 12 : newData.hour.value + 12) - : (newData.hour.value === 12 ? 0 : newData.hour.value); - } - - // merge new values from the picker's selection - // to the existing DatetimeData values - for (const key of Object.keys(newData)) { - (existingData as any)[key] = newData[key].value; - } - return true; - } else if (newData.ampm) { - // Even though in the picker column hour values are between 1 and 12, the hour value is actually normalized - // to [0, 23] interval. Because of this when changing between AM and PM we have to update the hour so it points - // to the correct HH hour - newData.hour = { - value: newData.hour - ? newData.hour.value - : (newData.ampm.value === 'pm' - ? (existingData.hour! < 12 ? existingData.hour! + 12 : existingData.hour!) - : (existingData.hour! >= 12 ? existingData.hour! - 12 : existingData.hour)) - }; - existingData['hour'] = newData['hour'].value; - existingData['ampm'] = newData['ampm'].value; - return true; - } - - // eww, invalid data - console.warn(`Error parsing date: "${newData}". Please provide a valid ISO 8601 datetime format: https://www.w3.org/TR/NOTE-datetime`); - - } else { - // blank data, clear everything out - for (const k in existingData) { - if (existingData.hasOwnProperty(k)) { - delete (existingData as any)[k]; - } - } - } - return false; -}; - -export const parseTemplate = (template: string): string[] => { - const formats: string[] = []; - - template = template.replace(/[^\w\s]/gi, ' '); - - FORMAT_KEYS.forEach(format => { - if (format.f.length > 1 && template.indexOf(format.f) > -1 && template.indexOf(format.f + format.f.charAt(0)) < 0) { - template = template.replace(format.f, ' ' + format.f + ' '); - } - }); - - const words = template.split(' ').filter(w => w.length > 0); - words.forEach((word, i) => { - FORMAT_KEYS.forEach(format => { - if (word === format.f) { - if (word === FORMAT_A || word === FORMAT_a) { - // this format is an am/pm format, so it's an "a" or "A" - if ((formats.indexOf(FORMAT_h) < 0 && formats.indexOf(FORMAT_hh) < 0) || - VALID_AMPM_PREFIX.indexOf(words[i - 1]) === -1) { - // template does not already have a 12-hour format - // or this am/pm format doesn't have a hour, minute, or second format immediately before it - // so do not treat this word "a" or "A" as the am/pm format - return; - } - } - formats.push(word); - } - }); - }); - - return formats; -}; - -export const getValueFromFormat = (date: DatetimeData, format: string) => { - if (format === FORMAT_A || format === FORMAT_a) { - return (date.hour! < 12 ? 'am' : 'pm'); - } - if (format === FORMAT_hh || format === FORMAT_h) { - return (date.hour! > 12 ? date.hour! - 12 : (date.hour === 0 ? 12 : date.hour)); - } - return (date as any)[convertFormatToKey(format)!]; -}; - -export const convertFormatToKey = (format: string): string | undefined => { - for (const k in FORMAT_KEYS) { - if (FORMAT_KEYS[k].f === format) { - return FORMAT_KEYS[k].k; - } - } - return undefined; -}; - -export const convertDataToISO = (data: DatetimeData): string => { - // https://www.w3.org/TR/NOTE-datetime - let rtn = ''; - if (data.year !== undefined) { - // YYYY - rtn = fourDigit(data.year); - - if (data.month !== undefined) { - // YYYY-MM - rtn += '-' + twoDigit(data.month); - - if (data.day !== undefined) { - // YYYY-MM-DD - rtn += '-' + twoDigit(data.day); - - if (data.hour !== undefined) { - // YYYY-MM-DDTHH:mm:SS - rtn += `T${twoDigit(data.hour)}:${twoDigit(data.minute)}:${twoDigit(data.second)}`; - - if (data.millisecond! > 0) { - // YYYY-MM-DDTHH:mm:SS.SSS - rtn += '.' + threeDigit(data.millisecond); - } - - if (data.tzOffset === undefined) { - // YYYY-MM-DDTHH:mm:SSZ - rtn += 'Z'; - - } else { - - // YYYY-MM-DDTHH:mm:SS+/-HH:mm - rtn += (data.tzOffset > 0 ? '+' : '-') + twoDigit(Math.floor(Math.abs(data.tzOffset / 60))) + ':' + twoDigit(data.tzOffset % 60); - } - } - } - } - - } else if (data.hour !== undefined) { - // HH:mm - rtn = twoDigit(data.hour) + ':' + twoDigit(data.minute); - - if (data.second !== undefined) { - // HH:mm:SS - rtn += ':' + twoDigit(data.second); - - if (data.millisecond !== undefined) { - // HH:mm:SS.SSS - rtn += '.' + threeDigit(data.millisecond); - } - } - } - - return rtn; -}; - -/** - * Use to convert a string of comma separated strings or - * an array of strings, and clean up any user input - */ -export const convertToArrayOfStrings = (input: string | string[] | undefined | null, type: string): string[] | undefined => { - if (input == null) { - return undefined; - } - - if (typeof input === 'string') { - // convert the string to an array of strings - // auto remove any [] characters - input = input.replace(/\[|\]/g, '').split(','); - } - - let values: string[] | undefined; - if (Array.isArray(input)) { - // trim up each string value - values = input.map(val => val.toString().trim()); - } - - if (values === undefined || values.length === 0) { - console.warn(`Invalid "${type}Names". Must be an array of strings, or a comma separated string.`); - } - - return values; -}; - -/** - * Use to convert a string of comma separated numbers or - * an array of numbers, and clean up any user input - */ -export const convertToArrayOfNumbers = (input: any[] | string | number, type: string): number[] => { - if (typeof input === 'string') { - // convert the string to an array of strings - // auto remove any whitespace and [] characters - input = input.replace(/\[|\]|\s/g, '').split(','); - } - - let values: number[]; - if (Array.isArray(input)) { - // ensure each value is an actual number in the returned array - values = input - .map((num: any) => parseInt(num, 10)) - .filter(isFinite); - } else { - values = [input]; - } - - if (values.length === 0) { - console.warn(`Invalid "${type}Values". Must be an array of numbers, or a comma separated string of numbers.`); - } - - return values; -}; - -const twoDigit = (val: number | undefined): string => { - return ('0' + (val !== undefined ? Math.abs(val) : '0')).slice(-2); -}; - -const threeDigit = (val: number | undefined): string => { - return ('00' + (val !== undefined ? Math.abs(val) : '0')).slice(-3); -}; - -const fourDigit = (val: number | undefined): string => { - return ('000' + (val !== undefined ? Math.abs(val) : '0')).slice(-4); -}; - -export interface DatetimeData { - year?: number; - month?: number; - day?: number; - hour?: number; - minute?: number; - second?: number; - millisecond?: number; - tzOffset?: number; - ampm?: string; -} - -export interface LocaleData { - monthNames?: string[]; - monthShortNames?: string[]; - dayNames?: string[]; - dayShortNames?: string[]; -} - -const FORMAT_YYYY = 'YYYY'; -const FORMAT_YY = 'YY'; -const FORMAT_MMMM = 'MMMM'; -const FORMAT_MMM = 'MMM'; -const FORMAT_MM = 'MM'; -const FORMAT_M = 'M'; -const FORMAT_DDDD = 'DDDD'; -const FORMAT_DDD = 'DDD'; -const FORMAT_DD = 'DD'; -const FORMAT_D = 'D'; -const FORMAT_HH = 'HH'; -const FORMAT_H = 'H'; -const FORMAT_hh = 'hh'; -const FORMAT_h = 'h'; -const FORMAT_mm = 'mm'; -const FORMAT_m = 'm'; -const FORMAT_ss = 'ss'; -const FORMAT_s = 's'; -const FORMAT_A = 'A'; -const FORMAT_a = 'a'; - -const FORMAT_KEYS = [ - { f: FORMAT_YYYY, k: 'year' }, - { f: FORMAT_MMMM, k: 'month' }, - { f: FORMAT_DDDD, k: 'day' }, - { f: FORMAT_MMM, k: 'month' }, - { f: FORMAT_DDD, k: 'day' }, - { f: FORMAT_YY, k: 'year' }, - { f: FORMAT_MM, k: 'month' }, - { f: FORMAT_DD, k: 'day' }, - { f: FORMAT_HH, k: 'hour' }, - { f: FORMAT_hh, k: 'hour' }, - { f: FORMAT_mm, k: 'minute' }, - { f: FORMAT_ss, k: 'second' }, - { f: FORMAT_M, k: 'month' }, - { f: FORMAT_D, k: 'day' }, - { f: FORMAT_H, k: 'hour' }, - { f: FORMAT_h, k: 'hour' }, - { f: FORMAT_m, k: 'minute' }, - { f: FORMAT_s, k: 'second' }, - { f: FORMAT_A, k: 'ampm' }, - { f: FORMAT_a, k: 'ampm' }, -]; - -const DAY_NAMES = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', -]; - -const DAY_SHORT_NAMES = [ - 'Sun', - 'Mon', - 'Tue', - 'Wed', - 'Thu', - 'Fri', - 'Sat', -]; - -const MONTH_NAMES = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', -]; - -const MONTH_SHORT_NAMES = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', -]; - -const VALID_AMPM_PREFIX = [ - FORMAT_hh, FORMAT_h, FORMAT_mm, FORMAT_m, FORMAT_ss, FORMAT_s -]; diff --git a/core/src/components/datetime/datetime.ios.scss b/core/src/components/datetime/datetime.ios.scss index 803207692f..8acbc4f7f0 100644 --- a/core/src/components/datetime/datetime.ios.scss +++ b/core/src/components/datetime/datetime.ios.scss @@ -1,14 +1,373 @@ -@import "./datetime"; -@import "./datetime.ios.vars"; - -// iOS Datetime -// -------------------------------------------------- +@import "./datetime.scss"; +@import "./datetime.ios.vars.scss"; +@import "../../themes/ionic.globals.ios"; :host { - --placeholder-color: #{$datetime-ios-placeholder-color}; - --padding-top: #{$datetime-ios-padding-top}; - --padding-end: #{$datetime-ios-padding-end}; - --padding-bottom: #{$datetime-ios-padding-bottom}; - --padding-start: #{$datetime-ios-padding-start}; + --background: var(--ion-color-light, #ffffff); + --background-rgb: var(--ion-color-light-rgb); + --title-color: #{$text-color-step-400}; } +// Header +// ----------------------------------- +:host .datetime-header { + @include padding($datetime-ios-padding, $datetime-ios-padding, $datetime-ios-padding, $datetime-ios-padding); + + border-bottom: $datetime-ios-border-color; +} + +:host .datetime-header .datetime-title { + color: var(--title-color); + + font-size: 14px; +} + +// Calendar / Header / Action Buttons +// ----------------------------------- +:host .calendar-action-buttons ion-item { + --padding-start: #{$datetime-ios-padding}; + --background-hover: transparent; + --background-activated: transparent; + + font-size: 16px; + font-weight: 600; +} + +:host .calendar-action-buttons ion-item ion-icon, +:host .calendar-action-buttons ion-buttons ion-button { + color: current-color(base); +} + +:host .calendar-action-buttons ion-buttons { + @include padding($datetime-ios-padding / 2, 0, 0, 0); +} + +:host .calendar-action-buttons ion-buttons ion-button { + @include margin(0, 0, 0, 0); +} + +// Calendar / Header / Days of Week +// ----------------------------------- +:host .calendar-days-of-week { + @include padding(0, $datetime-ios-padding / 2, 0, $datetime-ios-padding / 2); + + color: $text-color-step-700; + + font-size: 12px; + + font-weight: 600; + + line-height: 24px; + + text-transform: uppercase; +} + +// Calendar / Body +// ----------------------------------- +:host .calendar-body .calendar-month .calendar-month-grid { + + /** + * We need to apply the padding to + * each month grid item otherwise + * older versions of WebKit will consider + * this padding a snapping point if applied + * on .calendar-month + */ + @include padding($datetime-ios-padding / 2, $datetime-ios-padding / 2, $datetime-ios-padding / 2, $datetime-ios-padding / 2); + + height: calc(100% - #{$datetime-ios-padding}); +} + +:host .calendar-day { + font-size: 20px; +} + +:host .calendar-day:after { + opacity: 0.2; +} + +:host .calendar-day:focus:after { + background: current-color(base); +} + +/** + * Day that today but not selected + * should have ion-color for text color. + */ +:host .calendar-day.calendar-day-today { + color: current-color(base); +} + +/** + * Day that is not today but + * is selected should have ion-color for + * text color and be bolder. + */ +:host .calendar-day.calendar-day-active { + color: current-color(base); + + font-weight: 600; +} + +:host .calendar-day.calendar-day-active:after { + background: current-color(base); +} + +/** + * Day that is not today but + * is selected should have ion-color for + * text color and be bolder. + */ +:host .calendar-day.calendar-day-active .calendar-day-content { + background: current-color(contrast); +} + +/** + * Day that is selected and is today + * should have white color. + */ +:host .calendar-day.calendar-day-today.calendar-day-active { + color: var(--ion-text-color, #ffffff); +} + +:host .calendar-day.calendar-day-today.calendar-day-active:after { + background: current-color(base); + + opacity: 1; +} + +:host .calendar-day { + font-size: 20px; +} + +:host .calendar-day:after { + opacity: 0.2; +} + +:host .calendar-day:focus:after { + background: current-color(base); +} + +/** + * Day that today but not selected + * should have ion-color for text color. + */ +:host .calendar-day.calendar-day-today { + color: current-color(base); +} + +/** + * Day that is not today but + * is selected should have ion-color for + * text color and be bolder. + */ +:host .calendar-day.calendar-day-active { + color: current-color(base); + + font-weight: 600; +} + +:host .calendar-day.calendar-day-active:after { + background: current-color(base); +} + +/** + * Day that is not today but + * is selected should have ion-color for + * text color and be bolder. + */ +:host .calendar-day.calendar-day-active .calendar-day-content { + background: current-color(contrast); +} + +/** + * Day that is selected and is today + * should have white color. + */ +:host .calendar-day.calendar-day-today.calendar-day-active { + color: var(--ion-text-color, #ffffff); +} + +:host .calendar-day.calendar-day-today.calendar-day-active:after { + background: current-color(base); + + opacity: 1; +} + +// Time / Header +// ----------------------------------- +:host .datetime-time { + @include padding($datetime-ios-padding / 2, $datetime-ios-padding, $datetime-ios-padding, $datetime-ios-padding); + + font-size: 16px; + font-weight: 600; +} + +:host .time-base { + @include border-radius($datetime-ios-time-border-radius, $datetime-ios-time-border-radius, $datetime-ios-time-border-radius, $datetime-ios-time-border-radius); + @include margin(0, $datetime-ios-padding / 2, 0, 0); + + width: $datetime-ios-time-width; + height: $datetime-ios-time-height; +} + +:host .time-column { + @include border-radius($datetime-ios-time-border-radius, $datetime-ios-time-border-radius, $datetime-ios-time-border-radius, $datetime-ios-time-border-radius); +} + +:host .time-item { + line-height: $datetime-ios-time-height; +} + +// Year Picker +// ----------------------------------- +:host(.show-month-and-year) .calendar-action-buttons ion-item { + --color: #{current-color(base)}; +} + +:host .datetime-year-body .datetime-picker-col { + @include margin(0, 10px, 0, 10px); + @include padding(0, $datetime-ios-padding, 0, $datetime-ios-padding); +} + +:host .datetime-picker-before { + @include position(0, null, null, 0); + + position: absolute; + + width: 100%; + + height: 82px; + + background: linear-gradient(to bottom, var(--background, var(--ion-background-color, #fff)) 20%, rgba(var(--background-rgb, var(--ion-background-color-rgb, 255, 255, 255)), 0.8) 100%); + + z-index: 10; + + pointer-events: none; +} + +:host .datetime-picker-after { + @include position(116px, null, null, 0); + + position: absolute; + + width: 100%; + + height: 115px; + + background: linear-gradient(to top, var(--background, var(--ion-background-color, #fff)) 30%, rgba(var(--background-rgb, var(--ion-background-color-rgb, 255, 255, 255)), 0.8) 100%); + + z-index: 10; + + pointer-events: none; +} + +:host .datetime-picker-highlight { + @include position(50%, 0, 0, 0); + @include border-radius($datetime-ios-time-border-radius, $datetime-ios-time-border-radius, $datetime-ios-time-border-radius, $datetime-ios-time-border-radius); + @include margin(0, auto, 0, auto); + + position: absolute; + + width: calc(100% - #{$datetime-ios-padding * 2}); + + height: 34px; + + transform: translateY(-50%); + + background: var(--ion-color-step-150, #eeeeef); + + z-index: -1; +} + +:host .datetime-year-body { + display: flex; + + position: relative; + + align-items: center; + + justify-content: center; + + font-size: 22px; + + /** + * This is required otherwise the + * highlight will appear behind + * the datetime. + */ + z-index: 0; +} + +:host .datetime-picker-col { + scroll-snap-type: y mandatory; + + /** + * Need to explicitly set overflow-x: hidden + * for older implementations of scroll snapping. + */ + overflow-x: hidden; + overflow-y: scroll; + + // Hide scrollbars on Firefox + scrollbar-width: none; + + height: 200px; + + outline: none; +} + +@media (any-hover: hover) { + :host .datetime-picker-col:focus { + background: current-color(base, 0.2); + } +} + +/** + * Hide scrollbars on Chrome and Safari + */ +:host .datetime-picker-col::-webkit-scrollbar { + display: none; +} + + +:host .picker-col-item { + height: 34px; + + line-height: 34px; + + scroll-snap-align: center; +} + +:host .picker-col-item-empty { + scroll-snap-align: none; +} + + +:host .datetime-year-body .datetime-picker-col:first-of-type { + text-align: left; +} + +:host .datetime-year-body .datetime-picker-col:last-of-type { + text-align: right; +} + +// Footer +// ----------------------------------- +:host .datetime-buttons { + @include padding($datetime-ios-padding / 2, $datetime-ios-padding / 2, $datetime-ios-padding / 2, $datetime-ios-padding / 2); + + border-top: $datetime-ios-border-color; +} + +:host .datetime-buttons ::slotted(ion-buttons), +:host .datetime-buttons ion-buttons { + display: flex; + + align-items: center; + justify-content: space-between; +} + +:host .datetime-action-buttons { + width: 100%; +} diff --git a/core/src/components/datetime/datetime.ios.vars.scss b/core/src/components/datetime/datetime.ios.vars.scss index 19af09fcba..c96495bd56 100644 --- a/core/src/components/datetime/datetime.ios.vars.scss +++ b/core/src/components/datetime/datetime.ios.vars.scss @@ -1,20 +1,17 @@ -@import "../../themes/ionic.globals.ios"; -@import "../item/item.ios.vars"; - // iOS Datetime // -------------------------------------------------- -/// @prop - Padding top of the datetime -$datetime-ios-padding-top: $item-ios-padding-top !default; +/// @prop - Border color for dividers between header and footer +$datetime-ios-border-color: 0.55px solid $background-color-step-200 !default; -/// @prop - Padding end of the datetime -$datetime-ios-padding-end: ($item-ios-padding-end / 2) !default; +/// @prop - Padding for content +$datetime-ios-padding: 16px !default; -/// @prop - Padding bottom of the datetime -$datetime-ios-padding-bottom: $item-ios-padding-bottom !default; +/// @prop - Height of the time picker +$datetime-ios-time-height: 28px !default; -/// @prop - Padding start of the datetime -$datetime-ios-padding-start: $item-ios-padding-start !default; +/// @prop - Width of the time picker +$datetime-ios-time-width: 68px !default; -/// @prop - Color of the datetime placeholder -$datetime-ios-placeholder-color: $text-color-step-600 !default; +/// @prop - Border radius of the time picker +$datetime-ios-time-border-radius: 8px !default; diff --git a/core/src/components/datetime/datetime.md.scss b/core/src/components/datetime/datetime.md.scss index 3712891ef9..d86e5f8e19 100644 --- a/core/src/components/datetime/datetime.md.scss +++ b/core/src/components/datetime/datetime.md.scss @@ -1,13 +1,259 @@ -@import "./datetime"; -@import "./datetime.md.vars"; - -// Material Design Datetime -// -------------------------------------------------- +@import "./datetime.scss"; +@import "./datetime.md.vars.scss"; +@import "../../themes/ionic.globals.md"; :host { - --placeholder-color: #{$datetime-md-placeholder-color}; - --padding-top: #{$datetime-md-padding-top}; - --padding-end: #{$datetime-md-padding-end}; - --padding-bottom: #{$datetime-md-padding-bottom}; - --padding-start: #{$datetime-md-padding-start}; + --background: var(--ion-color-step-100, #ffffff); + --title-color: #{current-color(contrast)}; +} + +// Header +// ----------------------------------- +:host .datetime-header { + @include padding($datetime-md-header-padding, $datetime-md-header-padding, $datetime-md-header-padding, $datetime-md-header-padding); + + background: current-color(base); + color: var(--title-color); +} + +:host .datetime-header .datetime-title { + font-size: $datetime-md-title-font-size; + + text-transform: uppercase; +} + +:host .datetime-header .datetime-selected-date { + @include margin(30px, null, null, null); + + font-size: $datetime-md-selected-date-font-size; +} + +// Calendar / Header / Action Buttons +// ----------------------------------- +:host .datetime-calendar .calendar-action-buttons ion-item { + --padding-start: #{$datetime-md-header-padding}; +} + +:host .calendar-action-buttons ion-item, +:host .calendar-action-buttons ion-button { + color: #{$text-color-step-350}; +} + +// Calendar / Header / Days of Week +// ----------------------------------- +:host .calendar-days-of-week { + @include padding(0px, 10px, 0px, 10px); + + color: $text-color-step-500; + + font-size: $datetime-md-calendar-item-font-size; + + line-height: 36px; +} + +// Calendar / Body +// ----------------------------------- +:host .calendar-body .calendar-month .calendar-month-grid { + @include padding(0px, 10px, 0px, 10px); + + /** + * Calendar on MD will show an empty row + * if not enough dates to fill 6th row. + * Calendar on iOS fits all dates into + * a fixed number of rows and resizes + * if necessary. + */ + grid-template-rows: repeat(6, 1fr); + +} + +// Individual day button in month +:host .calendar-day { + @include padding(13px, 0, 13px, 0px); + + font-size: $datetime-md-calendar-item-font-size; +} + +:host .calendar-day:focus:after { + background: current-color(base, 0.2); + + box-shadow: 0px 0px 0px 4px current-color(base, 0.2); +} + +/** + * Day that today but not selected + * should have ion-color for text color. + */ +:host .calendar-day.calendar-day-today { + color: current-color(base); +} + +:host .calendar-day.calendar-day-today:after { + border: 1px solid current-color(base); +} + +/** + * Day that is not today but + * is selected should have ion-color for + * text color and be bolder. + */ +:host .calendar-day.calendar-day-active { + color: current-color(contrast); +} + +:host .calendar-day.calendar-day-active:after { + border: 1px solid current-color(base); + + background: current-color(base); +} + +// Time / Header +// ----------------------------------- +:host .datetime-time { + @include padding($datetime-md-padding / 2, $datetime-md-padding, $datetime-md-padding / 2, $datetime-md-padding); +} + +:host .time-header { + color: #{$text-color-step-350}; +} + +:host .time-base { + @include border-radius($datetime-md-time-border-radius, $datetime-md-time-border-radius, $datetime-md-time-border-radius, $datetime-md-time-border-radius); + @include margin(0, $datetime-md-padding / 2, 0, 0); + + width: $datetime-md-time-width; + height: $datetime-md-time-height; +} + +:host .time-column { + @include border-radius($datetime-md-time-border-radius, $datetime-md-time-border-radius, $datetime-md-time-border-radius, $datetime-md-time-border-radius); +} + +:host .time-item { + line-height: $datetime-md-time-height; +} + +:host .time-ampm ion-segment { + @include border-radius($datetime-md-time-border-radius, $datetime-md-time-border-radius, $datetime-md-time-border-radius, $datetime-md-time-border-radius); + + border: 1px solid rgba($text-color-rgb, 0.1); +} + +:host .time-ampm ion-segment-button { + --indicator-height: 0px; + --background-checked: #{current-color(base, 0.1)}; + + min-height: $datetime-md-time-height + 2; +} + +:host .time-ampm ion-segment-button.segment-button-checked { + background: var(--background-checked); +} + +// Year Picker +// ----------------------------------- +:host(.show-month-and-year) .datetime-calendar { + flex: 0; +} +:host(.show-month-and-year) .datetime-year { + flex: 1; + + min-height: 0; +} + +:host(.show-month-and-year) .datetime-footer { + border-top: 1px solid var(--ion-color-step-250, #dddddd); +} + +:host .datetime-year-body { + @include padding(0, $datetime-md-padding, $datetime-md-padding, $datetime-md-padding); + + display: grid; + + grid-template-columns: repeat(auto-fit, minmax(74px, 1fr)); + grid-gap: 0px 6px; + + overflow-y: scroll; + -webkit-overflow-scrolling: touch; +} + +:host .datetime-year-item { + @include padding(0px, 0px, 0px, 0px); + @include margin(0px, 0px, 0px, 0px); + + position: relative; + + align-items: center; + justify-content: center; + + border: none; + + outline: none; + + background: none; + color: currentColor; + + cursor: pointer; + + appearance: none; + + z-index: 0; +} + +:host .datetime-year-item[disabled] { + pointer-events: none; + + opacity: 0.4; +} + +:host .datetime-year-item .datetime-year-inner { + @include border-radius(20px, 20px, 20px, 20px); + @include margin(10px, 10px, 10px, 10px); + + display: flex; + + align-items: center; + justify-content: center; + + height: 32px; + + border: 1px solid transparent; + + font-size: 16px; +} + +:host .datetime-current-year .datetime-year-inner { + border: 1px solid current-color(base); + + color: current-color(base); +} + +:host .datetime-active-year .datetime-year-inner { + border: 1px solid current-color(base); + + background: current-color(base); + color: current-color(contrast); +} + +@media (any-hover: hover) { + :host .datetime-year-item:hover, + :host .datetime-year-item:focus { + background: current-color(base, 0.1); + } +} + +// Footer +// ----------------------------------- +:host .datetime-buttons { + @include padding(10px, 10px, 10px, 10px); + + display: flex; + + align-items: center; + + justify-content: flex-end; +} + +:host .datetime-view-buttons ion-button { + color: $text-color-step-200; } diff --git a/core/src/components/datetime/datetime.md.vars.scss b/core/src/components/datetime/datetime.md.vars.scss index b5348c6d86..a735fa0d8d 100644 --- a/core/src/components/datetime/datetime.md.vars.scss +++ b/core/src/components/datetime/datetime.md.vars.scss @@ -1,20 +1,26 @@ -@import "../../themes/ionic.globals.md"; -@import "../item/item.md.vars"; - -// Material Design Datetime +// MD Datetime // -------------------------------------------------- -/// @prop - Padding top of the datetime -$datetime-md-padding-top: $item-md-padding-top !default; +/// @prop - Font size for title in header +$datetime-md-title-font-size: 12px !default; -/// @prop - Padding end of the datetime -$datetime-md-padding-end: 0 !default; +/// @prop - Font size for selected date in header +$datetime-md-selected-date-font-size: 34px !default; -/// @prop - Padding bottom of the datetime -$datetime-md-padding-bottom: $item-md-padding-bottom !default; +/// @prop - Font size for calendar day button +$datetime-md-calendar-item-font-size: 14px !default; -/// @prop - Padding start of the datetime -$datetime-md-padding-start: $item-md-padding-start !default; +/// @prop - Padding for content in header +$datetime-md-header-padding: 20px !default; -/// @prop - Color of the datetime placeholder -$datetime-md-placeholder-color: $placeholder-text-color !default; +/// @prop - Padding for content +$datetime-md-padding: 16px !default; + +/// @prop - Height of the time picker +$datetime-md-time-height: 28px !default; + +/// @prop - Width of the time picker +$datetime-md-time-width: 68px !default; + +/// @prop - Border radius of the time picker +$datetime-md-time-border-radius: 4px !default; diff --git a/core/src/components/datetime/datetime.scss b/core/src/components/datetime/datetime.scss index 5e0e855e21..a3c50d179c 100644 --- a/core/src/components/datetime/datetime.scss +++ b/core/src/components/datetime/datetime.scss @@ -5,63 +5,375 @@ :host { /** - * @prop --padding-top: Top padding of the datetime - * @prop --padding-end: Right padding if direction is left-to-right, and left padding if direction is right-to-left of the datetime - * @prop --padding-bottom: Bottom padding of the datetime - * @prop --padding-start: Left padding if direction is left-to-right, and right padding if direction is right-to-left of the datetime - * - * @prop --placeholder-color: Color of the datetime placeholder + * @prop --background: The primary background of the datetime component. + * @prop --background-rgb: The primary background of the datetime component in RGB format. + * @prop --title-color: The text color of the title. */ - @include padding(var(--padding-top), var(--padding-end), var(--padding-bottom), var(--padding-start)); display: flex; - position: relative; - min-width: $datetime-min-width; - min-height: $datetime-min-height; + flex-flow: column; - font-family: $font-family-base; + width: 350px; + height: 100%; + background: var(--background); + + overflow: hidden; +} + +:host .calendar-body, +:host .time-column, +:host .datetime-year { + opacity: 0; +} + +:host(:not(.datetime-ready)) .datetime-year { + position: absolute; + pointer-events: none; +} + +:host(.datetime-ready) .calendar-body, +:host(.datetime-ready) .time-column { + opacity: 1; +} + +:host(.datetime-ready) .datetime-year { + display: none; + + opacity: 1; +} + +// Calendar +// ----------------------------------- + +/** + * This allows the calendar to take + * up 100% of the remaining height. + * On iOS, if there are more than + * 5 rows of dates, the dates should + * be resized to fit into this + * container. + */ +:host .datetime-calendar, +:host .datetime-year { + display: flex; + + flex: 1 1 auto; + + flex-flow: column; +} + +:host(.show-month-and-year) .datetime-year { + display: flex; +} + +:host(.show-month-and-year) .calendar-next-prev, +:host(.show-month-and-year) .calendar-days-of-week, +:host(.show-month-and-year) .calendar-body, +:host(.show-month-and-year) .datetime-time { + display: none; +} + +:host(.datetime-readonly), +:host(.datetime-disabled) { + pointer-events: none; +} + +:host(.datetime-disabled) { + opacity: 0.4; +} + +/** + * Title should not wrap + * to the next line and should + * show ellipsis instead. + */ +:host .datetime-header .datetime-title { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; - z-index: $z-index-item-input; +} + +// Calendar / Header / Action Buttons +// ----------------------------------- + +/** + * Date/Year button should be on + * the opposite side of the component + * as the Next/Prev buttons + */ +:host .calendar-action-buttons { + display: flex; + + justify-content: space-between; +} + +:host .calendar-action-buttons ion-item, +:host .calendar-action-buttons ion-button { + --background: translucent; +} + +:host .calendar-action-buttons ion-item ion-label { + display: flex; + + align-items: center; +} + +:host .calendar-action-buttons ion-item ion-icon { + @include padding(0, 0, 0, 4px); +} + +// Calendar / Header / Days of Week +// ----------------------------------- +:host .calendar-days-of-week { + display: grid; + grid-template-columns: repeat(7, 1fr); + + text-align: center; +} + +// Calendar / Body +// ----------------------------------- +:host .calendar-body { + + /** + * Show all calendar months inline + * and allow them to take up 100% of + * the free space. Do not use CSS Grid + * here as there are issues with nested grid + * on older browsers. + */ + display: flex; + + flex-grow: 1; + + scroll-snap-type: x mandatory; + + /** + * Need to explicitly set overflow-y: hidden + * for older implementations of scroll snapping. + */ + overflow-x: scroll; + overflow-y: hidden; + + // Hide scrollbars on Firefox + scrollbar-width: none; + + /** + * Hide blue outline on calendar body + * when it is focused. + */ + outline: none; +} + +:host .calendar-body .calendar-month { + /** + * Swiping should snap to at + * most one month at a time. + */ + scroll-snap-align: start; + scroll-snap-stop: always; + + flex-shrink: 0; + + width: 100%; +} + +/** + * Hide scrollbars on Chrome and Safari + */ +:host .calendar-body::-webkit-scrollbar { + display: none; +} + +:host .calendar-body .calendar-month-grid { + /** + * Create 7 columns for + * 7 days in a week. + */ + display: grid; + grid-template-columns: repeat(7, 1fr); + + height: 100%; +} + +/** + * Center the day text vertically + * and horizontally within its grid cell. + */ +:host .calendar-day { + @include padding(0px, 0px, 0px, 0px); + @include margin(0px, 0px, 0px, 0px); + + display: flex; + + position: relative; + + align-items: center; + justify-content: center; + + border: none; + + outline: none; + + background: none; + color: currentColor; + + cursor: pointer; + + appearance: none; + + z-index: 0; +} + +:host .calendar-day[disabled] { + pointer-events: none; + + opacity: 0.4; +} + +:host .calendar-day:after { + @include border-radius(32px, 32px, 32px, 32px); + @include padding(4px, 4px, 4px, 4px); + /** + * Explicit position values are required here + * as pseudo element positioning is incorrect + * in older implementations of css grid. + */ + + @include position(50%, null, null, 50%); + + position: absolute; + + width: 32px; + height: 32px; + + transform: translate(-50%, -50%); + + content: " "; + + z-index: -1; +} + +// Time / Header +// ----------------------------------- + +:host .datetime-time { + display: flex; + + justify-content: space-between; +} + +:host .time-base { + display: flex; + + align-items: center; + justify-content: center; + + border: 2px solid transparent; + + background: rgba($text-color-rgb, 0.065); + + font-size: 22px; + font-weight: 400; + + text-align: center; + + overflow-y: hidden; +} + +:host .time-base.time-base-active { + border: 2px solid current-color(base); +} + +:host .time-wrapper { + display: flex; + + align-items: center; + justify-content: flex-end; + + height: 100%; +} + +:host .time-column { + position: relative; + + height: 100%; + + outline: none; + scroll-snap-type: y mandatory; + + overflow-y: scroll; + overflow-x: hidden; + + -webkit-overflow-scrolling: touch; + + scrollbar-width: none; +} + +@media (any-hover: hover) { + :host .time-column:focus { + outline: none; + + background: current-color(base, 0.2); + } +} + +:host .time-column.time-column-active { + background: transparent; + color: current-color(base); +} + +:host .time-base.time-base-active .time-column:not(.time-column-active), +:host .time-base.time-base-active .time-separator { + pointer-events: none; + + opacity: 0.4; +} + +:host .time-column::-webkit-scrollbar { + display: none; +} + +:host .time-column-hours .time-item { + text-align: end; +} + +:host .time-column-minutes .time-item { + text-align: start; +} + +:host .time-item { + scroll-snap-align: center; + + height: 100%; +} + +:host .time-separator { + height: 100%; +} + +:host .time-header { + display: flex; + + align-items: center; +} + +:host .time-body { + display: flex; +} + +:host .time-ampm { + width: 100px; +} + +:host .time-ampm ion-segment-button { + min-width: 50px; } :host(.in-item) { position: static; } - -:host(.datetime-placeholder) { - color: var(--placeholder-color); -} - -:host(.datetime-disabled) { - opacity: .3; - pointer-events: none; -} - -:host(.datetime-readonly) { - pointer-events: none; -} - -button { - @include input-cover(); -} - -.datetime-text { - @include text-inherit(); - - @include rtl() { - direction: rtl; - } - - flex: 1; - - min-height: inherit; - - direction: ltr; - overflow: inherit; -} diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index e2a2f3ba35..38e4be72a5 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -1,18 +1,65 @@ -import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h } from '@stencil/core'; +import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h, writeTask } from '@stencil/core'; +import { + caretDownSharp, + caretUpSharp, + chevronBack, + chevronDown, + chevronForward +} from 'ionicons/icons'; import { getIonMode } from '../../global/ionic-global'; -import { DatetimeChangeEventDetail, DatetimeOptions, PickerColumn, PickerColumnOption, PickerOptions, StyleEventDetail } from '../../interface'; -import { addEventListener, clamp, findItemLabel, renderHiddenInput } from '../../utils/helpers'; -import { pickerController } from '../../utils/overlays'; -import { hostContext } from '../../utils/theme'; +import { Color, DatetimeChangeEventDetail, DatetimeParts, Mode, StyleEventDetail } from '../../interface'; +import { startFocusVisible } from '../../utils/focus-visible'; +import { raf, renderHiddenInput } from '../../utils/helpers'; +import { createColorClasses } from '../../utils/theme'; -import { DatetimeData, LocaleData, convertDataToISO, convertFormatToKey, convertToArrayOfNumbers, convertToArrayOfStrings, dateDataSortValue, dateSortValue, dateValueRange, daysInMonth, getDateValue, getTimezoneOffset, parseDate, parseTemplate, renderDatetime, renderTextFormat, updateDate } from './datetime-util'; +import { + generateMonths, + generateTime, + getCalendarYears, + getDaysOfMonth, + getDaysOfWeek, + getPickerMonths, + getToday +} from './utils/data'; +import { + addTimePadding, + getFormattedHour, + getMonthAndDay, + getMonthAndYear +} from './utils/format'; +import { + is24Hour +} from './utils/helpers'; +import { + calculateHourFromAMPM, + convertDataToISO, + getEndOfWeek, + getInternalHourValue, + getNextDay, + getNextMonth, + getNextWeek, + getPreviousDay, + getPreviousMonth, + getPreviousWeek, + getStartOfWeek +} from './utils/manipulation'; +import { + convertToArrayOfNumbers, + getPartsFromCalendarDay, + parseDate +} from './utils/parse'; +import { + getCalendarDayState, + getCalendarYearState, + isDayDisabled +} from './utils/state'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * - * @part text - The value of the datetime. - * @part placeholder - The placeholder of the datetime. + * @slot title - The title of the datetime. + * @slot buttons - The buttons in the datetime. */ @Component({ tag: 'ion-datetime', @@ -25,15 +72,56 @@ import { DatetimeData, LocaleData, convertDataToISO, convertFormatToKey, convert export class Datetime implements ComponentInterface { private inputId = `ion-dt-${datetimeIds++}`; - private locale: LocaleData = {}; - private datetimeMin: DatetimeData = {}; - private datetimeMax: DatetimeData = {}; - private datetimeValue: DatetimeData = {}; - private buttonEl?: HTMLButtonElement; + private calendarBodyRef?: HTMLElement; + private timeBaseRef?: HTMLElement; + private timeHourRef?: HTMLElement; + private timeMinuteRef?: HTMLElement; + private monthRef?: HTMLElement; + private yearRef?: HTMLElement; + private clearFocusVisible?: () => void; + private overlayIsPresenting = false; + + private parsedMinuteValues?: number[]; + private parsedHourValues?: number[]; + private parsedMonthValues?: number[]; + private parsedYearValues?: number[]; + private parsedDayValues?: number[]; + + private minParts?: any; + private maxParts?: any; + + @State() showMonthAndYear = false; + + @State() activeParts: DatetimeParts = { + month: 5, + day: 28, + year: 2021, + hour: 13, + minute: 52, + ampm: 'pm' + } + + @State() workingParts: DatetimeParts = { + month: 5, + day: 28, + year: 2021, + hour: 13, + minute: 52, + ampm: 'pm' + } + + private todayParts = parseDate(getToday()) @Element() el!: HTMLIonDatetimeElement; - @State() isExpanded = false; + @State() isPresented = false; + + /** + * The color to use from your application's color palette. + * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. + * For more information on colors, see [theming](/docs/theming/basics). + */ + @Prop() color?: Color = 'primary'; /** * The name of the control, which is submitted with the form data. @@ -65,6 +153,11 @@ export class Datetime implements ComponentInterface { */ @Prop({ mutable: true }) min?: string; + @Watch('min') + protected minChanged() { + this.processMinParts(); + } + /** * The maximum datetime allowed. Value must be a date string * following the @@ -75,32 +168,19 @@ export class Datetime implements ComponentInterface { */ @Prop({ mutable: true }) max?: 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`. - */ - @Prop() displayFormat = 'MMM D, YYYY'; + @Watch('max') + protected maxChanged() { + this.processMaxParts(); + } /** - * The timezone to use for display purposes only. See - * [Date.prototype.toLocaleString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleString) - * for a list of supported timezones. If no value is provided, the - * component will default to displaying times in the user's local timezone. + * Which values you want to select. `'date'` will show + * a calendar picker to select the month, day, and year. `'time'` + * will show a time picker to select the hour, minute, and (optionally) + * AM/PM. `'date-time'` will show the date picker first and time picker second. + * `'time-date'` will show the time picker first and date picker second. */ - @Prop() displayTimezone?: 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`. - */ - @Prop() pickerFormat?: string; + @Prop() presentation: 'date-time' | 'time-date' | 'date' | 'time' = 'date-time'; /** * The text to display on the picker's cancel button. @@ -120,6 +200,10 @@ export class Datetime implements ComponentInterface { * recent leap years, then this input's value would be `yearValues="2024,2020,2016,2012,2008"`. */ @Prop() yearValues?: number[] | number | string; + @Watch('yearValues') + protected yearValuesChanged() { + this.parsedYearValues = convertToArrayOfNumbers(this.yearValues); + } /** * Values used to create the list of selectable months. By default @@ -130,6 +214,10 @@ export class Datetime implements ComponentInterface { * zero-based index, meaning January's value is `1`, and December's is `12`. */ @Prop() monthValues?: number[] | number | string; + @Watch('monthValues') + protected monthValuesChanged() { + this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues); + } /** * Values used to create the list of selectable days. By default @@ -140,6 +228,10 @@ export class Datetime implements ComponentInterface { * days which are not valid for the selected month. */ @Prop() dayValues?: number[] | number | string; + @Watch('dayValues') + protected dayValuesChanged() { + this.parsedDayValues = convertToArrayOfNumbers(this.dayValues); + } /** * Values used to create the list of selectable hours. By default @@ -148,6 +240,10 @@ export class Datetime implements ComponentInterface { * array of numbers, or a string of comma separated numbers. */ @Prop() hourValues?: number[] | number | string; + @Watch('hourValues') + protected hourValuesChanged() { + this.parsedHourValues = convertToArrayOfNumbers(this.hourValues); + } /** * Values used to create the list of selectable minutes. By default @@ -157,43 +253,18 @@ export class Datetime implements ComponentInterface { * then this input value would be `minuteValues="0,15,30,45"`. */ @Prop() minuteValues?: number[] | number | string; + @Watch('minuteValues') + protected minuteValuesChanged() { + this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues); + } /** - * Full names for each month name. This can be used to provide - * locale month names. Defaults to English. + * The locale to use for `ion-datetime`. This + * impacts month and day name formatting. + * The `'default'` value refers to the default + * locale set by your device. */ - @Prop() monthNames?: string[] | string; - - /** - * Short abbreviated names for each month name. This can be used to provide - * locale month names. Defaults to English. - */ - @Prop() monthShortNames?: string[] | string; - - /** - * Full day of the week names. This can be used to provide - * locale names for each day in the week. Defaults to English. - */ - @Prop() dayNames?: string[] | string; - - /** - * Short abbreviated day of the week names. This can be used to provide - * locale names for each day in the week. Defaults to English. - * Defaults to: `['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']` - */ - @Prop() dayShortNames?: string[] | string; - - /** - * Any additional options that the picker interface can accept. - * See the [Picker API docs](../picker) for the picker options. - */ - @Prop() pickerOptions?: DatetimeOptions; - - /** - * The text to display when there's no date selected yet. - * Using lowercase to match the input attribute - */ - @Prop() placeholder?: string | null; + @Prop() locale = 'default'; /** * The value of the datetime as a valid ISO 8601 datetime string. @@ -205,13 +276,30 @@ export class Datetime implements ComponentInterface { */ @Watch('value') protected valueChanged() { - this.updateDatetimeValue(this.value); this.emitStyle(); this.ionChange.emit({ value: this.value }); } + /** + * If `true`, a header will be shown above the calendar + * picker. On `ios` mode this will include the + * slotted title, and on `md` mode this will include + * the slotted title and the selected date. + */ + @Prop() showDefaultTitle = false; + + /** + * If `true`, the default "Cancel" and "OK" buttons + * will be rendered at the bottom of the `ion-datetime` + * component. Developers can also use the `button` slot + * if they want to customize these buttons. If custom + * buttons are set in the `button` slot then the + * default buttons will not be rendered. + */ + @Prop() showDefaultButtons = false; + /** * Emitted when the datetime selection was cancelled. */ @@ -238,464 +326,1118 @@ export class Datetime implements ComponentInterface { */ @Event() ionStyle!: EventEmitter; - componentWillLoad() { - // 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 - this.locale = { - // this.locale[type] = convertToArrayOfStrings((this[type] ? this[type] : this.config.get(type), type); - monthNames: convertToArrayOfStrings(this.monthNames, 'monthNames'), - monthShortNames: convertToArrayOfStrings(this.monthShortNames, 'monthShortNames'), - dayNames: convertToArrayOfStrings(this.dayNames, 'dayNames'), - dayShortNames: convertToArrayOfStrings(this.dayShortNames, 'dayShortNames') - }; + /** + * Confirms the selected datetime value, updates the + * `value` property, and optionally closes the popover + * or modal that the datetime was presented in. + */ + @Method() + async confirm(closeOverlay = false) { + /** + * Prevent convertDataToISO from doing any + * kind of transformation based on timezone + * This cancels out any change it attempts to make + * + * Important: Take the timezone offset based on + * the date that is currently selected, otherwise + * there can be 1 hr difference when dealing w/ DST + */ + const date = new Date(convertDataToISO(this.workingParts)); + this.workingParts.tzOffset = date.getTimezoneOffset() * -1; - this.updateDatetimeValue(this.value); - this.emitStyle(); + this.value = convertDataToISO(this.workingParts); + + if (closeOverlay) { + this.closeParentOverlay(); + } } /** - * Opens the datetime overlay. + * Resets the internal state of the datetime + * but does not update the value. Passing a value + * ISO-8601 string will reset the state of + * te component to the provided date. */ @Method() - async open() { - if (this.disabled || this.isExpanded) { + async reset(value?: string) { + this.processValue(value); + } + + /** + * Emits the ionCancel event and + * optionally closes the popover + * or modal that the datetime was + * presented in. + */ + @Method() + async cancel(closeOverlay = false) { + this.ionCancel.emit(); + + if (closeOverlay) { + this.closeParentOverlay(); + } + } + + private closeParentOverlay = () => { + const popoverOrModal = this.el.closest('ion-modal, ion-popover') as HTMLIonModalElement | HTMLIonPopoverElement | null; + if (popoverOrModal) { + popoverOrModal.dismiss(); + } + } + + private setWorkingParts = (parts: DatetimeParts) => { + this.workingParts = { + ...parts + } + } + + private setActiveParts = (parts: DatetimeParts) => { + this.activeParts = { + ...parts + } + + const hasSlottedButtons = this.el.querySelector('[slot="buttons"]') !== null; + if (hasSlottedButtons || this.showDefaultButtons) { return; } + + this.confirm(); + } + + private initializeKeyboardListeners = () => { + const { calendarBodyRef } = this; + if (!calendarBodyRef) { return; } + + const root = this.el!.shadowRoot!; + + /** + * Get a reference to the month + * element we are currently viewing. + */ + const currentMonth = calendarBodyRef.querySelector('.calendar-month:nth-of-type(2)')!; + + /** + * When focusing the calendar body, we want to pass focus + * to the working day, but other days should + * only be accessible using the arrow keys. Pressing + * Tab should jump between bodies of selectable content. + */ + const checkCalendarBodyFocus = (ev: MutationRecord[]) => { + const record = ev[0]; + + /** + * If calendar body was already focused + * when this fired or if the calendar body + * if not currently focused, we should not re-focus + * the inner day. + */ + if ( + record.oldValue?.includes('ion-focused') || + !calendarBodyRef.classList.contains('ion-focused') + ) { + return; + } + + this.focusWorkingDay(currentMonth); + } + const mo = new MutationObserver(checkCalendarBodyFocus); + mo.observe(calendarBodyRef, { attributeFilter: ['class'], attributeOldValue: true }); + + /** + * We must use keydown not keyup as we want + * to prevent scrolling when using the arrow keys. + */ + this.calendarBodyRef!.addEventListener('keydown', (ev: KeyboardEvent) => { + const activeElement = root.activeElement; + if (!activeElement || !activeElement.classList.contains('calendar-day')) { return; } + + const parts = getPartsFromCalendarDay(activeElement as HTMLElement) + + let partsToFocus: DatetimeParts | undefined; + switch (ev.key) { + case 'ArrowDown': + ev.preventDefault(); + partsToFocus = getNextWeek(parts); + break; + case 'ArrowUp': + ev.preventDefault(); + partsToFocus = getPreviousWeek(parts); + break; + case 'ArrowRight': + ev.preventDefault(); + partsToFocus = getNextDay(parts); + break; + case 'ArrowLeft': + ev.preventDefault(); + partsToFocus = getPreviousDay(parts); + break; + case 'Home': + ev.preventDefault(); + partsToFocus = getStartOfWeek(parts); + break; + case 'End': + ev.preventDefault(); + partsToFocus = getEndOfWeek(parts); + break; + case 'PageUp': + ev.preventDefault(); + partsToFocus = getPreviousMonth(parts); + break; + case 'PageDown': + ev.preventDefault(); + partsToFocus = getNextMonth(parts); + break; + /** + * Do not preventDefault here + * as we do not want to override other + * browser defaults such as pressing Enter/Space + * to select a day. + */ + default: + return; + } + + /** + * If the day we want to move focus to is + * disabled, do not do anything. + */ + if (isDayDisabled(partsToFocus, this.minParts, this.maxParts)) { + return; + } + + this.setWorkingParts({ + ...this.workingParts, + ...partsToFocus + }) + + /** + * Give view a chance to re-render + * then move focus to the new working day + */ + requestAnimationFrame(() => this.focusWorkingDay(currentMonth)); + }) + } + + private focusWorkingDay = (currentMonth: Element) => { + /** + * Get the number of padding days so + * we know how much to offset our next selector by + * to grab the correct calenday-day element. + */ + const padding = currentMonth.querySelectorAll('.calendar-day-padding'); + const { day } = this.workingParts; + + if (day === null) { return; } + + /** + * Get the calendar day element + * and focus it. + */ + const dayEl = currentMonth.querySelector(`.calendar-day:nth-of-type(${padding.length + day})`) as HTMLElement | null; + if (dayEl) { + dayEl.focus(); + } + } + + private processMinParts = () => { + if (this.min === undefined) { + this.minParts = undefined; return; } - const pickerOptions = this.generatePickerOptions(); - const picker = await pickerController.create(pickerOptions); + const { month, day, year, hour, minute } = parseDate(this.min); - this.isExpanded = true; - picker.onDidDismiss().then(() => { - this.isExpanded = false; - this.setFocus(); - }); - addEventListener(picker, 'ionPickerColChange', async (event: any) => { - const data = event.detail; + this.minParts = { + month, + day, + year, + hour, + minute + } + } - const colSelectedIndex = data.selectedIndex; - const colOptions = data.options; + private processMaxParts = () => { + if (this.max === undefined) { + this.maxParts = undefined; + return; + } - const changeData: any = {}; - changeData[data.name] = { - value: colOptions[colSelectedIndex].value - }; + const { month, day, year, hour, minute } = parseDate(this.max); - if (data.name !== 'ampm' && this.datetimeValue.ampm !== undefined) { - changeData['ampm'] = { - value: this.datetimeValue.ampm - }; + this.maxParts = { + month, + day, + year, + hour, + minute + } + } + + private initializeCalendarIOListeners = () => { + const { calendarBodyRef } = this; + if (!calendarBodyRef) { return; } + + const mode = getIonMode(this); + + /** + * For performance reasons, we only render 3 + * months at a time: The current month, the previous + * month, and the next month. We have IntersectionObservers + * on the previous and next month elements to append/prepend + * new months. + * + * We can do this because Stencil is smart enough to not + * re-create the .calendar-month containers, but rather + * update the content within those containers. + * + * As an added bonus, WebKit has some troubles with + * scroll-snap-stop: always, so not rendering all of + * the months in a row allows us to mostly sidestep + * that issue. + */ + const months = calendarBodyRef.querySelectorAll('.calendar-month'); + + const startMonth = months[0] as HTMLElement; + const workingMonth = months[1] as HTMLElement; + const endMonth = months[2] as HTMLElement; + + /** + * Before setting up the IntersectionObserver, + * scroll the middle month into view. + * scrollIntoView() will scroll entire page + * if element is not in viewport. Use scrollLeft instead. + */ + writeTask(() => { + calendarBodyRef.scrollLeft = startMonth.clientWidth; + + let endIO: IntersectionObserver | undefined; + let startIO: IntersectionObserver | undefined; + const ioCallback = (callbackType: 'start' | 'end', entries: IntersectionObserverEntry[]) => { + const refIO = (callbackType === 'start') ? startIO : endIO; + const refMonth = (callbackType === 'start') ? startMonth : endMonth; + const refMonthFn = (callbackType === 'start') ? getPreviousMonth : getNextMonth; + + /** + * If the month is not fully in view, do not do anything + */ + const ev = entries[0]; + if (!ev.isIntersecting) { return; } + + /** + * When presenting an inline overlay, + * subsequent presentations will cause + * the IO to fire again (since the overlay + * is now visible and therefore the calendar + * months are intersecting). + */ + if (this.overlayIsPresenting) { + this.overlayIsPresenting = false; + return; + } + + /** + * On iOS, we need to set pointer-events: none + * when the user is almost done with the gesture + * so that they cannot quickly swipe while + * the scrollable container is snapping. + * Updating the container while snapping + * causes WebKit to snap incorrectly. + */ + if (mode === 'ios') { + const ratio = ev.intersectionRatio; + const shouldDisable = Math.abs(ratio - 0.7) <= 0.1; + + if (shouldDisable) { + calendarBodyRef.style.setProperty('pointer-events', 'none'); + return; + } + } + + /** + * Prevent scrolling for other browsers + * to give the DOM time to update and the container + * time to properly snap. + */ + calendarBodyRef.style.setProperty('overflow', 'hidden'); + + /** + * Remove the IO temporarily + * otherwise you can sometimes get duplicate + * events when rubber banding. + */ + if (refIO === undefined) { return; } + refIO.disconnect(); + + /** + * Use a writeTask here to ensure + * that the state is updated and the + * correct month is scrolled into view + * in the same frame. This is not + * typically a problem on newer devices + * but older/slower device may have a flicker + * if we did not do this. + */ + writeTask(() => { + const { month, year, day } = refMonthFn(this.workingParts); + + this.setWorkingParts({ + ...this.workingParts, + month, + day: day!, + year + }); + + workingMonth.scrollIntoView(false); + calendarBodyRef.style.removeProperty('overflow'); + calendarBodyRef.style.removeProperty('pointer-events'); + + /** + * Now that state has been updated + * and the correct month is in view, + * we can resume the IO. + */ + // tslint:disable-next-line + if (refIO === undefined) { return; } + refIO.observe(refMonth); + }); } - this.updateDatetimeValue(changeData); - picker.columns = this.generateColumns(); + /** + * Listen on the first month to + * prepend a new month and on the last + * month to append a new month. + * The 0.7 threshold is required on ios + * so that we can remove pointer-events + * when adding new months. + * Adding to a scroll snapping container + * while the container is snapping does not + * completely work as expected in WebKit. + * Adding pointer-events: none allows us to + * avoid these issues. + * + * This should be fine on Chromium, but + * when you set pointer-events: none + * it applies to active gestures which is not + * something WebKit does. + */ + endIO = new IntersectionObserver(ev => ioCallback('end', ev), { + threshold: mode === 'ios' ? [0.7, 1] : 1, + root: calendarBodyRef + }); + endIO.observe(endMonth); + + startIO = new IntersectionObserver(ev => ioCallback('start', ev), { + threshold: mode === 'ios' ? [0.7, 1] : 1, + root: calendarBodyRef + }); + startIO.observe(startMonth); + }); + } + + connectedCallback() { + this.clearFocusVisible = startFocusVisible(this.el); + } + + disconnectedCallback() { + if (this.clearFocusVisible) { + this.clearFocusVisible(); + this.clearFocusVisible = undefined; + } + } + + componentDidLoad() { + const mode = getIonMode(this); + + /** + * If a scrollable element is hidden using `display: none`, + * it will not have a scroll height meaning we cannot scroll elements + * into view. As a result, we will need to wait for the datetime to become + * visible if used inside of a modal or a popover otherwise the scrollable + * areas will not have the correct values snapped into place. + */ + let visibleIO: IntersectionObserver | undefined; + const visibleCallback = (entries: IntersectionObserverEntry[]) => { + const ev = entries[0]; + if (!ev.isIntersecting) { return; } + + /** + * This needs to run at most once for initial setup. + */ + visibleIO!.disconnect() + + this.initializeCalendarIOListeners(); + this.initializeKeyboardListeners(); + this.initializeTimeScrollListener(); + this.initializeOverlayListener(); + + if (mode === 'ios') { + this.initializeMonthAndYearScrollListeners(); + } + + /** + * TODO: Datetime needs a frame to ensure that it + * can properly scroll contents into view. As a result + * we hide the scrollable content until after that frame + * so users do not see the content quickly shifting. The downside + * is that the content will pop into view a frame after. Maybe there + * is a better way to handle this? + */ + writeTask(() => { + this.el.classList.add('datetime-ready'); + }); + } + visibleIO = new IntersectionObserver(visibleCallback, { threshold: 0.01 }); + visibleIO.observe(this.el); + } + + /** + * When doing subsequent presentations of an inline + * overlay, the IO callback will fire again causing + * the calendar to go back one month. We need to listen + * for the presentation of the overlay so we can properly + * cancel that IO callback. + */ + private initializeOverlayListener = () => { + const overlay = this.el.closest('ion-popover, ion-modal'); + if (overlay === null) { return; } + + overlay.addEventListener('willPresent', () => { + this.overlayIsPresenting = true; }); - await picker.present(); + } + + private initializeMonthAndYearScrollListeners = () => { + const { monthRef, yearRef } = this; + if (!yearRef || !monthRef) { return; } + + const { year, month } = this.workingParts; + + /** + * Scroll initial month and year into view. + * scrollIntoView() will scroll entire page + * if element is not in viewport. Use scrollTop instead. + */ + const initialYear = yearRef.querySelector(`.picker-col-item[data-value="${year}"]`) as HTMLElement | null; + if (initialYear) { + yearRef.scrollTop = initialYear.offsetTop - (initialYear.clientHeight * 2); + } + + const initialMonth = monthRef.querySelector(`.picker-col-item[data-value="${month}"]`) as HTMLElement | null; + if (initialMonth) { + monthRef.scrollTop = initialMonth.offsetTop - (initialMonth.clientHeight * 2); + } + + let timeout: any; + const scrollCallback = (colType: string) => { + raf(() => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + + const activeCol = colType === 'month' ? monthRef : yearRef; + timeout = setTimeout(() => { + + const bbox = activeCol.getBoundingClientRect(); + + /** + * Select item in the center of the column + * which is the month/year that we want to select + */ + const centerX = bbox.x + (bbox.width / 2); + const centerY = bbox.y + (bbox.height / 2); + + const activeElement = this.el!.shadowRoot!.elementFromPoint(centerX, centerY)!; + const dataValue = activeElement.getAttribute('data-value'); + + /** + * If no value it is + * possible we hit one of the + * empty padding columns. + */ + if (dataValue === null) { + return; + } + + const value = parseInt(dataValue, 10); + if (colType === 'month') { + this.setWorkingParts({ + ...this.workingParts, + month: value + }); + } else { + this.setWorkingParts({ + ...this.workingParts, + year: value + }); + } + + /** + * If the year changed, it is possible that + * the allowed month values have changed and the scroll + * position got reset + */ + raf(() => { + const { month: workingMonth, year: workingYear } = this.workingParts; + const monthEl = monthRef.querySelector(`.picker-col-item[data-value='${workingMonth}']`); + const yearEl = yearRef.querySelector(`.picker-col-item[data-value='${workingYear}']`); + + if (monthEl) { + monthEl.scrollIntoView({ block: 'center', inline: 'center' }); + } + + if (yearEl) { + yearEl.scrollIntoView({ block: 'center', inline: 'center' }); + } + }); + }, 250); + }) + } + /** + * Add scroll listeners to the month and year containers. + * Wrap this in an raf so that the scroll callback + * does not fire when we do our initial scrollIntoView above. + */ + raf(() => { + monthRef.addEventListener('scroll', () => scrollCallback('month')); + yearRef.addEventListener('scroll', () => scrollCallback('year')); + }); + } + + private initializeTimeScrollListener = () => { + const { timeBaseRef, timeHourRef, timeMinuteRef } = this; + if (!timeBaseRef || !timeHourRef || !timeMinuteRef) { return; } + + const { hour, minute } = this.workingParts; + + /** + * Scroll initial hour and minute into view. + * scrollIntoView() will scroll entire page + * if element is not in viewport. Use scrollTop instead. + */ + raf(() => { + const initialHour = timeHourRef.querySelector(`.time-item[data-value="${hour}"]`) as HTMLElement | null; + if (initialHour) { + timeHourRef.scrollTop = initialHour.offsetTop; + } + const initialMinute = timeMinuteRef.querySelector(`.time-item[data-value="${minute}"]`) as HTMLElement | null; + if (initialMinute) { + timeMinuteRef.scrollTop = initialMinute.offsetTop; + } + + /** + * Highlight the container and + * appropriate column when scrolling. + */ + let timeout: any; + const scrollCallback = (colType: string) => { + raf(() => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + + const activeCol = colType === 'hour' ? timeHourRef : timeMinuteRef; + const otherCol = colType === 'hour' ? timeMinuteRef : timeHourRef; + + timeBaseRef.classList.add('time-base-active'); + activeCol.classList.add('time-column-active'); + + timeout = setTimeout(() => { + timeBaseRef.classList.remove('time-base-active'); + activeCol.classList.remove('time-column-active'); + otherCol.classList.remove('time-column-active'); + + const bbox = activeCol.getBoundingClientRect(); + const activeElement = this.el!.shadowRoot!.elementFromPoint(bbox.x + 1, bbox.y + 1)!; + const value = parseInt(activeElement.getAttribute('data-value')!, 10); + + if (colType === 'hour') { + this.setWorkingParts({ + ...this.workingParts, + hour: value + }); + } else { + this.setWorkingParts({ + ...this.workingParts, + minute: value + }); + } + }, 250); + }); + } + + /** + * Add scroll listeners to the hour and minute containers. + * Wrap this in an raf so that the scroll callback + * does not fire when we do our initial scrollIntoView above. + */ + raf(() => { + timeHourRef.addEventListener('scroll', () => scrollCallback('hour')); + timeMinuteRef.addEventListener('scroll', () => scrollCallback('minute')); + }); + }); + } + + private processValue = (value?: string | null) => { + const valueToProcess = value || getToday(); + const { month, day, year, hour, minute, tzOffset } = parseDate(valueToProcess); + + this.workingParts = { + month, + day, + year, + hour, + minute, + tzOffset, + ampm: hour >= 12 ? 'pm' : 'am' + } + this.activeParts = { + month, + day, + year, + hour, + minute, + tzOffset, + ampm: hour >= 12 ? 'pm' : 'am' + } + + } + + componentWillLoad() { + this.processValue(this.value); + this.processMinParts(); + this.processMaxParts(); + this.parsedHourValues = convertToArrayOfNumbers(this.hourValues); + this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues); + this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues); + this.parsedYearValues = convertToArrayOfNumbers(this.yearValues); + this.parsedDayValues = convertToArrayOfNumbers(this.dayValues); + this.emitStyle(); } private emitStyle() { this.ionStyle.emit({ 'interactive': true, 'datetime': true, - 'has-placeholder': this.placeholder != null, - 'has-value': this.hasValue(), 'interactive-disabled': this.disabled, }); } - private updateDatetimeValue(value: any) { - updateDate(this.datetimeValue, value, this.displayTimezone); - } + private nextMonth = () => { + const { calendarBodyRef } = this; + if (!calendarBodyRef) { return; } - private generatePickerOptions(): PickerOptions { - const mode = getIonMode(this); - this.locale = { - monthNames: convertToArrayOfStrings(this.monthNames, 'monthNames'), - monthShortNames: convertToArrayOfStrings(this.monthShortNames, 'monthShortNames'), - dayNames: convertToArrayOfStrings(this.dayNames, 'dayNames'), - dayShortNames: convertToArrayOfStrings(this.dayShortNames, 'dayShortNames') - }; - const pickerOptions: PickerOptions = { - mode, - ...this.pickerOptions, - columns: this.generateColumns() - }; + const nextMonth = calendarBodyRef.querySelector('.calendar-month:last-of-type'); + if (!nextMonth) { return; } - // If the user has not passed in picker buttons, - // add a cancel and ok button to the picker - const buttons = pickerOptions.buttons; - if (!buttons || buttons.length === 0) { - pickerOptions.buttons = [ - { - text: this.cancelText, - role: 'cancel', - handler: () => { - this.updateDatetimeValue(this.value); - this.ionCancel.emit(); - } - }, - { - text: this.doneText, - handler: (data: any) => { - this.updateDatetimeValue(data); - - /** - * Prevent convertDataToISO from doing any - * kind of transformation based on timezone - * This cancels out any change it attempts to make - * - * Important: Take the timezone offset based on - * the date that is currently selected, otherwise - * there can be 1 hr difference when dealing w/ DST - */ - const date = new Date(convertDataToISO(this.datetimeValue)); - - // If a custom display timezone is provided, use that tzOffset value instead - this.datetimeValue.tzOffset = (this.displayTimezone !== undefined && this.displayTimezone.length > 0) - ? ((getTimezoneOffset(date, this.displayTimezone)) / 1000 / 60) * -1 - : date.getTimezoneOffset() * -1; - - this.value = convertDataToISO(this.datetimeValue); - } - } - ]; - } - return pickerOptions; - } - - private generateColumns(): PickerColumn[] { - // if a picker format wasn't provided, then fallback - // to use the display format - let template = this.pickerFormat || this.displayFormat || DEFAULT_FORMAT; - if (template.length === 0) { - return []; - } - // make sure we've got up to date sizing information - this.calcMinMax(); - - // does not support selecting by day name - // automatically 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" - const columns = parseTemplate(template).map((format: any) => { - // loop through each format in the template - // create a new picker column to build up with data - const key = convertFormatToKey(format)!; - let values: any[]; - - // check if they have exact values to use for this date part - // otherwise use the default date part values - const self = this as any; - values = self[key + 'Values'] - ? convertToArrayOfNumbers(self[key + 'Values'], key) - : dateValueRange(format, this.datetimeMin, this.datetimeMax); - - const colOptions = values.map(val => { - return { - value: val, - text: renderTextFormat(format, val, undefined, this.locale), - }; - }); - - // cool, we've loaded up the columns with options - // preselect the option for this column - const optValue = getDateValue(this.datetimeValue, format); - - const selectedIndex = colOptions.findIndex(opt => opt.value === optValue); - - return { - name: key, - selectedIndex: selectedIndex >= 0 ? selectedIndex : 0, - options: colOptions - }; + nextMonth.scrollIntoView({ + behavior: 'smooth', + block: 'end', + inline: 'nearest' }); - - // Normalize min/max - const min = this.datetimeMin as any; - const max = this.datetimeMax as any; - ['month', 'day', 'hour', 'minute'] - .filter(name => !columns.find(column => column.name === name)) - .forEach(name => { - min[name] = 0; - max[name] = 0; - }); - - return this.validateColumns(divyColumns(columns)); } - private validateColumns(columns: PickerColumn[]) { - const today = new Date(); - const minCompareVal = dateDataSortValue(this.datetimeMin); - const maxCompareVal = dateDataSortValue(this.datetimeMax); - const yearCol = columns.find(c => c.name === 'year'); + private prevMonth = () => { + const { calendarBodyRef } = this; + if (!calendarBodyRef) { return; } - let selectedYear: number = today.getFullYear(); - if (yearCol) { - // default to the first value if the current year doesn't exist in the options - if (!yearCol.options.find(col => col.value === today.getFullYear())) { - selectedYear = yearCol.options[0].value; - } + const prevMonth = calendarBodyRef.querySelector('.calendar-month:first-of-type'); + if (!prevMonth) { return; } - const selectedIndex = yearCol.selectedIndex; - if (selectedIndex !== undefined) { - const yearOpt = yearCol.options[selectedIndex] as PickerColumnOption | undefined; - if (yearOpt) { - // they have a selected year value - selectedYear = yearOpt.value; - } - } - } + prevMonth.scrollIntoView({ + behavior: 'smooth', + block: 'end', + inline: 'nearest' + }); + } - const selectedMonth = this.validateColumn(columns, - 'month', 1, - minCompareVal, maxCompareVal, - [selectedYear, 0, 0, 0, 0], - [selectedYear, 12, 31, 23, 59] + private renderFooter() { + const hasSlottedButtons = this.el.querySelector('[slot="buttons"]') !== null; + if (!hasSlottedButtons && !this.showDefaultButtons) { return; } + + /** + * By default we render two buttons: + * Cancel - Dismisses the datetime and + * does not update the `value` prop. + * OK - Dismisses the datetime and + * updates the `value` prop. + */ + return ( + ); + } - const numDaysInMonth = daysInMonth(selectedMonth, selectedYear); - const selectedDay = this.validateColumn(columns, - 'day', 2, - minCompareVal, maxCompareVal, - [selectedYear, selectedMonth, 0, 0, 0], - [selectedYear, selectedMonth, numDaysInMonth, 23, 59] + private toggleMonthAndYearView = () => { + this.showMonthAndYear = !this.showMonthAndYear; + } + + private renderMDYearView() { + return getCalendarYears(this.activeParts, true, undefined, undefined, this.parsedYearValues).map(year => { + + const { isCurrentYear, isActiveYear, disabled, ariaSelected } = getCalendarYearState(year, this.workingParts, this.todayParts, this.minParts, this.maxParts); + return ( + + ) + }) + } + + private renderiOSYearView() { + return [ +
, +
, +
, +
this.monthRef = el} tabindex="0"> +
 
+
 
+
 
+ {getPickerMonths(this.locale, this.workingParts, this.minParts, this.maxParts, this.parsedMonthValues).map(month => { + return ( +
{ + const target = ev.target as HTMLElement; + target.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }); + }} + >{month.text}
+ ) + })} +
 
+
 
+
 
+
, +
this.yearRef = el} tabindex="0"> +
 
+
 
+
 
+ {getCalendarYears(this.workingParts, false, this.minParts, this.maxParts, this.parsedYearValues).map(year => { + return ( +
{ + const target = ev.target as HTMLElement; + target.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }); + }} + >{year}
+ ) + })} +
 
+
 
+
 
+
+ ] + } + + private renderYearView(mode: Mode) { + return ( +
+
+ {mode === 'ios' ? this.renderiOSYearView() : this.renderMDYearView()} +
+
); + } - const selectedHour = this.validateColumn(columns, - 'hour', 3, - minCompareVal, maxCompareVal, - [selectedYear, selectedMonth, selectedDay, 0, 0], - [selectedYear, selectedMonth, selectedDay, 23, 59] + private renderCalendarHeader(mode: Mode) { + const expandedIcon = mode === 'ios' ? chevronDown : caretUpSharp; + const collapsedIcon = mode === 'ios' ? chevronForward : caretDownSharp; + return ( +
+
+
+ this.toggleMonthAndYearView()}> + + {getMonthAndYear(this.locale, this.workingParts)} + + +
+ +
+ + this.prevMonth()}> + + + this.nextMonth()}> + + + +
+
+
+ {getDaysOfWeek(this.locale, mode).map(d => { + return
{d}
+ })} +
+
+ ) + } + + private renderMonth(month: number, year: number) { + const yearAllowed = this.parsedYearValues === undefined || this.parsedYearValues.includes(year); + const monthAllowed = this.parsedMonthValues === undefined || this.parsedMonthValues.includes(month); + const isMonthDisabled = !yearAllowed || !monthAllowed; + return ( +
+
+ {getDaysOfMonth(month, year).map((dateObject, index) => { + const { day, dayOfWeek } = dateObject; + const referenceParts = { month, day, year }; + const { isActive, isToday, ariaLabel, ariaSelected, disabled } = getCalendarDayState(this.locale, referenceParts, this.activeParts, this.todayParts, this.minParts, this.maxParts, this.parsedDayValues); + + return ( + + ) + })} +
+
+ ) + } + + private renderCalendarBody() { + return ( +
this.calendarBodyRef = el} tabindex="0"> + {generateMonths(this.workingParts).map(({ month, year }) => { + return this.renderMonth(month, year); + })} +
+ ) + } + + private renderCalendar(mode: Mode) { + return ( +
+ {this.renderCalendarHeader(mode)} + {this.renderCalendarBody()} +
+ ) + } + + /** + * Render time picker inside of datetime. + * Do not pass color prop to segment on + * iOS mode. MD segment has been customized and + * should take on the color prop, but iOS + * should just be the default segment. + */ + private renderTime(mode: Mode) { + const use24Hour = is24Hour(this.locale); + const { ampm } = this.workingParts; + const { hours, minutes, am, pm } = generateTime(this.locale, this.workingParts, this.minParts, this.maxParts, this.parsedHourValues, this.parsedMinuteValues); + return ( +
+
Time
+
+
this.timeBaseRef = el}> +
+
this.timeHourRef = el} + tabindex="0" + > + { hours.map(hour => { + return ( +
{getFormattedHour(hour, use24Hour)}
+ ) + })} +
+
:
+
this.timeMinuteRef = el} + tabindex="0" + > + { minutes.map(minute => { + return ( +
{addTimePadding(minute)}
+ ) + })} +
+
+
+ { !use24Hour &&
+ { + + /** + * Since datetime uses 24-hour time internally + * we need to update the working hour here as well + * if the user is using a 12-hour time format. + */ + const { value } = ev.detail; + const hour = calculateHourFromAMPM(this.workingParts, value); + + this.setWorkingParts({ + ...this.workingParts, + ampm: value, + hour + }); + + /** + * Do not let this event bubble up + * otherwise developers listening for ionChange + * on the datetime will see this event. + */ + ev.stopPropagation(); + }} + > + AM + PM + +
} +
+
+ ) + } + + private renderCalendarViewHeader(mode: Mode) { + const hasSlottedTitle = this.el.querySelector('[slot="title"]') !== null; + if (!hasSlottedTitle && !this.showDefaultTitle) { return; } + + return ( +
+
+ Select Date +
+ {mode === 'md' &&
+ {getMonthAndDay(this.locale, this.activeParts)} +
} +
); - - this.validateColumn(columns, - 'minute', 4, - minCompareVal, maxCompareVal, - [selectedYear, selectedMonth, selectedDay, selectedHour, 0], - [selectedYear, selectedMonth, selectedDay, selectedHour, 59] - ); - - return columns; } - private calcMinMax() { - const todaysYear = new Date().getFullYear(); - - if (this.yearValues !== undefined) { - const years = convertToArrayOfNumbers(this.yearValues, 'year'); - if (this.min === undefined) { - this.min = Math.min(...years).toString(); - } - if (this.max === undefined) { - this.max = Math.max(...years).toString(); - } - } else { - if (this.min === undefined) { - this.min = (todaysYear - 100).toString(); - } - if (this.max === undefined) { - this.max = todaysYear.toString(); - } + private renderDatetime(mode: Mode) { + const { presentation } = this; + switch (presentation) { + case 'date-time': + return [ + this.renderCalendarViewHeader(mode), + this.renderCalendar(mode), + this.renderYearView(mode), + this.renderTime(mode), + this.renderFooter() + ] + case 'time-date': + return [ + this.renderCalendarViewHeader(mode), + this.renderTime(mode), + this.renderCalendar(mode), + this.renderYearView(mode), + this.renderFooter() + ] + case 'time': + return [ + this.renderTime(mode), + this.renderFooter() + ] + case 'date': + return [ + this.renderCalendarViewHeader(mode), + this.renderCalendar(mode), + this.renderYearView(mode), + this.renderFooter() + ] } - const min = this.datetimeMin = parseDate(this.min)!; - const max = this.datetimeMax = parseDate(this.max)!; - - min.year = min.year || todaysYear; - max.year = max.year || todaysYear; - - min.month = min.month || 1; - max.month = max.month || 12; - min.day = min.day || 1; - max.day = max.day || 31; - min.hour = min.hour || 0; - max.hour = max.hour === undefined ? 23 : max.hour; - min.minute = min.minute || 0; - max.minute = max.minute === undefined ? 59 : max.minute; - min.second = min.second || 0; - max.second = max.second === undefined ? 59 : max.second; - - // Ensure min/max constraints - if (min.year > max.year) { - console.error('min.year > max.year'); - min.year = max.year - 100; - } - if (min.year === max.year) { - if (min.month > max.month) { - console.error('min.month > max.month'); - min.month = 1; - } else if (min.month === max.month && min.day > max.day) { - console.error('min.day > max.day'); - min.day = 1; - } - } - } - - private validateColumn(columns: PickerColumn[], name: string, index: number, min: number, max: number, lowerBounds: number[], upperBounds: number[]): number { - const column = columns.find(c => c.name === name); - if (!column) { - return 0; - } - - const lb = lowerBounds.slice(); - const ub = upperBounds.slice(); - const options = column.options; - let indexMin = options.length - 1; - let indexMax = 0; - - for (let i = 0; i < options.length; i++) { - const opts = options[i]; - const value = opts.value; - lb[index] = opts.value; - ub[index] = opts.value; - - const disabled = opts.disabled = ( - value < lowerBounds[index] || - value > upperBounds[index] || - dateSortValue(ub[0], ub[1], ub[2], ub[3], ub[4]) < min || - dateSortValue(lb[0], lb[1], lb[2], lb[3], lb[4]) > max - ); - if (!disabled) { - indexMin = Math.min(indexMin, i); - indexMax = Math.max(indexMax, i); - } - } - const selectedIndex = column.selectedIndex = clamp(indexMin, column.selectedIndex!, indexMax); - const opt = column.options[selectedIndex] as PickerColumnOption | undefined; - if (opt) { - return opt.value; - } - return 0; - } - - private get text() { - // create the text of the formatted data - const template = this.displayFormat || this.pickerFormat || DEFAULT_FORMAT; - - if ( - this.value === undefined || - this.value === null || - this.value.length === 0 - ) { return; } - - return renderDatetime(template, this.datetimeValue, this.locale); - } - - private hasValue(): boolean { - return this.text !== undefined; - } - - private setFocus() { - if (this.buttonEl) { - this.buttonEl.focus(); - } - } - - private onClick = () => { - this.setFocus(); - this.open(); - } - - private onFocus = () => { - this.ionFocus.emit(); - } - - private onBlur = () => { - this.ionBlur.emit(); } render() { - const { inputId, text, disabled, readonly, isExpanded, el, placeholder } = this; + const { name, value, disabled, el, color, isPresented, readonly, showMonthAndYear, presentation } = this; const mode = getIonMode(this); - const labelId = inputId + '-lbl'; - const label = findItemLabel(el); - const addPlaceholderClass = (text === undefined && placeholder != null) ? true : false; - // If selected text has been passed in, use that first - // otherwise use the placeholder - const datetimeText = text === undefined - ? (placeholder != null ? placeholder : '') - : text; - - const datetimeTextPart = text === undefined - ? (placeholder != null ? 'placeholder' : undefined) - : 'text'; - - if (label) { - label.id = labelId; - } - - renderHiddenInput(true, el, this.name, this.value, this.disabled); + renderHiddenInput(true, el, name, value, disabled); return ( -
{datetimeText}
- + {this.renderDatetime(mode)}
); } } -const divyColumns = (columns: PickerColumn[]): PickerColumn[] => { - const columnsWidth: number[] = []; - let col: PickerColumn; - let width: number; - for (let i = 0; i < columns.length; i++) { - col = columns[i]; - columnsWidth.push(0); - - for (const option of col.options) { - width = option.text!.length; - if (width > columnsWidth[i]) { - columnsWidth[i] = width; - } - } - } - - if (columnsWidth.length === 2) { - width = Math.max(columnsWidth[0], columnsWidth[1]); - columns[0].align = 'right'; - columns[1].align = 'left'; - columns[0].optionsWidth = columns[1].optionsWidth = `${width * 17}px`; - - } else if (columnsWidth.length === 3) { - width = Math.max(columnsWidth[0], columnsWidth[2]); - columns[0].align = 'right'; - columns[1].columnWidth = `${columnsWidth[1] * 17}px`; - columns[0].optionsWidth = columns[2].optionsWidth = `${width * 17}px`; - columns[2].align = 'left'; - } - return columns; -}; - -const DEFAULT_FORMAT = 'MMM D, YYYY'; - let datetimeIds = 0; diff --git a/core/src/components/datetime/readme.md b/core/src/components/datetime/readme.md index 3dfbf85522..fa99ef2ff3 100644 --- a/core/src/components/datetime/readme.md +++ b/core/src/components/datetime/readme.md @@ -1,91 +1,6 @@ # ion-datetime -Datetimes present a picker interface from the bottom of a page, making it easy for -users to select dates and times. The picker displays scrollable columns that can be -used to individually select years, months, days, hours and minute values. Datetimes -are similar to the native `input` elements of type `datetime-local`, however, Ionic's -Datetime component makes it easy to display the date and time in a preferred format, -and manage the datetime values. - - -## Display and Picker Formats - -The datetime component displays the values in two places: in the `` component, -and in the picker interface that is presented from the bottom of the screen. The following -chart lists all of the formats that can be used. - -| Format | Description | Example | -| ------ | ------------------------------ | ----------------------- | -| `YYYY` | Year, 4 digits | `2018` | -| `YY` | Year, 2 digits | `18` | -| `M` | Month | `1` ... `12` | -| `MM` | Month, leading zero | `01` ... `12` | -| `MMM` | Month, short name | `Jan` | -| `MMMM` | Month, full name | `January` | -| `D` | Day | `1` ... `31` | -| `DD` | Day, leading zero | `01` ... `31` | -| `DDD` | Day, short name | `Fri` | -| `DDDD` | Day, full name | `Friday` | -| `H` | Hour, 24-hour | `0` ... `23` | -| `HH` | Hour, 24-hour, leading zero | `00` ... `23` | -| `h` | Hour, 12-hour | `1` ... `12` | -| `hh` | Hour, 12-hour, leading zero | `01` ... `12` | -| `a` | 12-hour time period, lowercase | `am` `pm` | -| `A` | 12-hour time period, uppercase | `AM` `PM` | -| `m` | Minute | `1` ... `59` | -| `mm` | Minute, leading zero | `01` ... `59` | -| `s` | Second | `1` ... `59` | -| `ss` | Second, leading zero | `01` ... `59` | -| `Z` | UTC Timezone Offset | `Z or +HH:mm or -HH:mm` | - -**Important**: See the [Month Names and Day of the Week -Names](#month-names-and-day-of-the-week-names) section below on how to use -different names for the month and day. - -### Display Format - -The `displayFormat` property specifies how a datetime's value should be -printed, as formatted text, within the datetime component. - -A few examples are provided in the chart below. The formats mentioned -above can be passed in to the display format in any combination. - -| Display Format | Example | -| ----------------------| ----------------------- | -| `M-YYYY` | `6-2005` | -| `MM/YY` | `06/05` | -| `MMM YYYY` | `Jun 2005` | -| `YYYY, MMMM` | `2005, June` | -| `MMM DD, YYYY HH:mm` | `Jun 17, 2005 11:06` | - -**Important**: `ion-datetime` will by default display values relative to the user's timezone. -Given a value of `09:00:00+01:00`, the datetime component will -display it as `04:00:00-04:00` for users in a `-04:00` timezone offset. -To change the display to use a different timezone, use the displayTimezone property described below. - -### Display Timezone - -The `displayTimezone` property allows you to change the default behavior -of displaying values relative to the user's local timezone. In addition to "UTC" valid -time zone values are determined by the browser, and in most cases follow the time zone names -of the [IANA time zone database](https://www.iana.org/time-zones), such as "Asia/Shanghai", -"Asia/Kolkata", "America/New_York". In the following example: - -```html - -``` - -The displayed value will not be converted and will be displayed as provided (UTC). - - -### Picker Format - -The `pickerFormat` property determines which columns should be shown in the picker -interface, the order of the columns, and which format to use within each -column. If `pickerFormat` is not provided then it will use the value of -`displayFormat`. Refer to the chart in the [Display Format](#display-format) section -for some formatting examples. - +Datetimes present a calendar interface and time wheel, making it easy for users to select dates and times. Datetimes are similar to the native `input` elements of `datetime-local`, however, Ionic Framework's Datetime componetn makes it easy to display the date and time in the a preferred format, and manage the datetime values. ### Datetime Data @@ -95,21 +10,19 @@ 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. -Fortunately, Ionic's datetime input has been designed so developers can avoid -the common pitfalls, allowing developers to easily format datetime values within -the input, and give the user a simple datetime picker for a great user -experience. +Fortunately, Ionic Framework's datetime input has been designed so developers can avoid +the common pitfalls, allowing developers to easily manipulate datetime values and give the user a simple datetime picker for a great user experience. ##### ISO 8601 Datetime Format: YYYY-MM-DDTHH:mmZ -Ionic uses the [ISO 8601 datetime format](https://www.w3.org/TR/NOTE-datetime) +Ionic Framework uses the [ISO 8601 datetime format](https://www.w3.org/TR/NOTE-datetime) for its value. The value is simply a string, rather than using JavaScript's `Date` object. Using the ISO datetime format makes it easy to serialize and parse within JSON objects and databases. 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 value, Ionic will continue to use +can be used, and after a user selects a new value, Ionic Framework will continue to use the same ISO format which datetime value was originally given as. | Description | Format | Datetime Value Example | @@ -129,49 +42,62 @@ 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`. -Also note that neither the `displayFormat` nor the `pickerFormat` -can set the datetime value's output, which is the value that is set by the -component's `ngModel`. The formats 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's selection there should -be at least some form of restricting the dates that can be selected. By 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. +By default, there is no maximum or minimum date a user can select. To customize the minimum and maximum datetime values, the `min` and `max` component properties can be provided which may make more sense for the app's use-case. Following the same IS0 8601 format listed in the table above, each component can restrict which dates can be selected by the user. By passing `2016` to the `min` property and `2020-10-31` to the `max` property, the datetime will restrict the date selection between the beginning of `2016`, and `October 31st of 2020`. -To customize the minimum and maximum datetime values, the `min` and `max` -component properties 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. By passing `2016` to the `min` property and `2020-10-31` -to the `max` property, the datetime will restrict the date selection between the -beginning of 2016, and October 31st of 2020. +## Selecting Specific Values +While the `min` and `max` properties allow you to restrict date selection to a certain range, the `monthValues`, `dayValues`, `yearValues`, `hourValues`, and `minuteValues` properties allow you choose specific days and times that you to have enabled. -## Month Names and Day of the Week Names +For example, if we wanted users to only select minutes in increments of 15, we could pass `"0,15,30,45"` to the `minuteValues` property. -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. +As another example, if we wanted users to only select from the month of October, we could pass `"10"` to the `monthValues` property. -The 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DatetimeFormat#Browser_compatibility) have adopted. +## Customizing Date and Time Presentation -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. +Some use cases may call for only date selection or only time selection. The `presentation` property allows you to specify which pickers to show and the order to show them in. For example, `presentation="time"` would only show the time picker. `presentation="time-date"` would show the time picker first and the date picker second, but `presentation="date-time"` would show the date picker first and the time picker second. -Additionally, Angular also provides an internationalization service, but it is still -under heavy development so Ionic does not depend on it at this time. +## Reset and Cancel Buttons -The current best practice is to 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 configured at the app level, or individual `ion-datetime` level. +`ion-datetime` provides `cancel` and `reset` methods that you can call when clicking on custom buttons that you have provided in the `buttons` slot. The `reset` method also allows you to provide a date to reset the datetime to. +## Confirming Selected Values -### Advanced Datetime Validation and Manipulation +By default, `ionChange` is emitted with the new datetime value whenever a new date is selected. To require user confirmation before emitting `ionChange`, you can either set the `showDefaultButtons` property to `true` or use the `buttons` slot to pass in a custom confirmation button. When passing in custom buttons, the confirm button must call the `confirm` method on `ion-datetime` for `ionChange` to be emitted. + +## Localization + +Ionic Framework makes use of the [Intl.DatetimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DatetimeFormat) Web API which allows us to automatically localize the month and day names according to the language and region set on the user's device. + +For instances where you need a specific locale, you can use the `locale` property to set it. The following example sets the language to "French" and the region to "France": + +```html + +``` + +## Parsing Dates + +When `ionChange` is emitted, we provide an ISO-8601 string in the event payload. From there, it is the developer's responsibility to format it as they see fit. We recommend using a library like [date-fns](https://date-fns.org) to format their dates properly. + +Below is an example of formatting an ISO-8601 string to display the month, date, and year: + +```typescript +import { format, parseISO } from 'date-fns'; + +/** + * This is provided in the event + * payload from the `ionChange` event. + */ +const dateFromIonDatetime = '2021-06-04T14:23:00-04:00'; +const formattedString = format(parseISO(dateFromIonDatetime), 'MMM d, yyyy'); + +console.log(formattedString); // Jun 4, 2021 +``` + +See https://date-fns.org/docs/format for a list of all the valid format tokens. + +## 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 @@ -183,7 +109,6 @@ subtracting 30 minutes, etc.), or even formatting data to a specific locale, then we highly recommend using [date-fns](https://date-fns.org) to work with dates in JavaScript. - @@ -192,107 +117,67 @@ dates in JavaScript. ### Angular ```html - - MMMM - - + + - - MM DD YY - - + + - - Disabled - - + + - - YYYY - - + + - - MMMM YY - - + + - - MM/DD/YYYY - - + + - - MM/DD/YYYY - - + + - - DDD. MMM DD, YY (custom locale) - - + + - - D MMM YYYY H:mm - - + + - - DDDD MMM D, YYYY - - + + +
My Custom Title
+
- - HH:mm - - + + + + Good to go! + Reset + + - - h:mm a - - + +Open Datetime Modal + + + + + - - hh:mm A (15 min steps) - - - - - Leap years, summer months - - - - - Specific days/months/years - - -``` - -```typescript +```javascript @Component({…}) export class MyComponent { - customYearValues = [2020, 2016, 2008, 2004, 2000, 1996]; - customDayShortNames = ['s\u00f8n', 'man', 'tir', 'ons', 'tor', 'fre', 'l\u00f8r']; - customPickerOptions: any; - - constructor() { - this.customPickerOptions = { - buttons: [{ - text: 'Save', - handler: () => console.log('Clicked Save!') - }, { - text: 'Log', - handler: () => { - console.log('Clicked Log. Do not Dismiss.'); - return false; - } - }] - } + @ViewChild('customDatetime', { static: false }) datetime: HTMLIonDateTimeElement; + constructor() {} + + confirm() { + this.datetime.nativeEl.confirm(); + } + + reset() { + this.datetime.nativeEl.reset(); } - } ``` @@ -300,264 +185,153 @@ export class MyComponent { ### Javascript ```html - - MMMM - - + + - - MM DD YY - - + + - - Disabled - - + + - - YYYY - - + + - - MMMM YY - - + + - - MM/DD/YYYY - - + + - - MM/DD/YYYY - - + + - - DDD. MMM DD, YY (custom locale) - - + + - - D MMM YYYY H:mm - - + + - - DDDD MMM D, YYYY - - + + +
My Custom Title
+
- - HH:mm - - + + + + Good to go! + Reset + + - - h:mm a - - - - - hh:mm A (15 min steps) - - - - - Leap years, summer months - - - - - Specific days/months/years - - -``` + +Open Datetime Modal + + + + + ```javascript -var yearValuesArray = [2020, 2016, 2008, 2004, 2000, 1996]; -var customYearValues = document.getElementById('customYearValues'); -customYearValues.yearValues = yearValuesArray; +const datetime = document.querySelector('#custom-datetime'); -var dayShortNamesArray = [ - 's\u00f8n', - 'man', - 'tir', - 'ons', - 'tor', - 'fre', - 'l\u00f8r' -]; -var customDayShortNames = document.getElementById('customDayShortNames'); -customDayShortNames.dayShortNames = dayShortNamesArray; - -var customPickerButtons = { - buttons: [{ - text: 'Save', - handler: () => console.log('Clicked Save!') - }, { - text: 'Log', - handler: () => { - console.log('Clicked Log. Do not Dismiss.'); - return false; - } - }] +const confirm = () => { + datetime.confirm(); +} + +const reset = () => { + datetime.reset(); } -var customPickerOptions = document.getElementById('customPickerOptions'); -customPickerOptions.pickerOptions = customPickerButtons; ``` ### React -```tsx -import React, { useState } from 'react'; -import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar, IonItem, IonLabel, IonDatetime, IonFooter } from '@ionic/react'; - -const customYearValues = [2020, 2016, 2008, 2004, 2000, 1996]; - -const customDayShortNames = [ - 's\u00f8n', - 'man', - 'tir', - 'ons', - 'tor', - 'fre', - 'l\u00f8r' -]; +```javascript +import React, { useState, useRef } from 'react'; +import { + IonButton, + IonButtons, + IonContent, + IonDatetime, + IonModal, + IonPage +} from '@ionic/react'; export const DateTimeExamples: React.FC = () => { const [selectedDate, setSelectedDate] = useState('2012-12-15T13:47:20.789'); + const customDatetime = useRef(); + const confirm = () => { + if (customDatetime === undefined) return; + + customDatetime.confirm(); + } + + const reset = () => { + if (customDatetime === undefined) return; + + customDatetime.reset(); + } + return ( - - - IonDatetime Examples - - - - - MMMM - setSelectedDate(e.detail.value!)}> - - - - MM DD YY - setSelectedDate(e.detail.value!)}> - - - - Disabled - setSelectedDate(e.detail.value!)}> - - - - YYYY - console.log('Clicked Save!') - }, { - text: 'Log', - handler: () => { - console.log('Clicked Log. Do not Dismiss.'); - return false; - } - } - ] - }} - placeholder="Custom Options" displayFormat="YYYY" min="1981" max="2002" - value={selectedDate} onIonChange={e => setSelectedDate(e.detail.value!)}> - - - - - MMMM YY - setSelectedDate(e.detail.value!)}> - - - - MM/DD/YYYY - setSelectedDate(e.detail.value!)}> - - - - MM/DD/YYYY - setSelectedDate(e.detail.value!)}> - - - - DDD. MMM DD, YY (custom locale) - setSelectedDate(e.detail.value!)} - > - - - - D MMM YYYY H:mm - setSelectedDate(e.detail.value!)}> - - - - DDDD MMM D, YYYY - setSelectedDate(e.detail.value!)}> - - - - HH:mm - setSelectedDate(e.detail.value!)}> - - - - h:mm a - setSelectedDate(e.detail.value!)}> - - - - hh:mm A (15 min steps) - setSelectedDate(e.detail.value!)}> - - - - Leap years, summer months - setSelectedDate(e.detail.value!)}> - - - - Specific days/months/years - setSelectedDate(e.detail.value!)} - > - - - - - Selected Date: {selectedDate ?? '(none)'} - - + {/* Initial value */} + setSelectedDate(e.detail.value!)}> + + {/* Readonly */} + + + {/* Disabled */} + + + {/* Custom locale */} + + + {/* Max and min */} + + + {/* 15 minute increments */} + + + {/* Specific days/months/years */} + + + {/* Selecting time, no date */} + + + {/* Selecting time first, date second */} + + + {/* Custom title */} + +
My Custom Title
+
+ + {/* Custom buttons */} + + + confirm()}>Good to go! + reset()}>Reset + + + + {/* Datetime in overlay */} + Open Datetime Modal + + + + +
- ); -}; + ) +} ``` ### Stencil -```tsx +```javascript import { Component, h } from '@stencil/core'; @Component({ @@ -565,101 +339,72 @@ import { Component, h } from '@stencil/core'; styleUrl: 'datetime-example.css' }) export class DatetimeExample { - private customYearValues = [2020, 2016, 2008, 2004, 2000, 1996]; - private customDayShortNames = ['s\u00f8n', 'man', 'tir', 'ons', 'tor', 'fre', 'l\u00f8r']; - private customPickerOptions = { - buttons: [{ - text: 'Save', - handler: () => console.log('Clicked Save!') - }, { - text: 'Log', - handler: () => { - console.log('Clicked Log. Do not Dismiss.'); - return false; - } - }] + private customDatetime?: HTMLElement; + + private confirm() { + const { customDatetime } = this; + if (customDatetime === undefined) return; + + customDatetime.confirm(); } + private reset() { + const { customDatetime } = this; + if (customDatetime === undefined) return; + + customDatetime.reset(); + } + render() { return [ - - MMMM - - , - - - MM DD YY - - , - - - Disabled - - , - - - YYYY - - , - - - MMMM YY - - , - - - MM/DD/YYYY - - , - - - MM/DD/YYYY - - , - - - DDD. MMM DD, YY (custom locale) - - , - - - D MMM YYYY H:mm - - , - - - DDDD MMM D, YYYY - - , - - - HH:mm - - , - - - h:mm a - - , - - - hh:mm A (15 min steps) - - , - - - Leap years, summer months - - , - - - Specific days/months/years - - - ]; + {/* Initial value */} + , + + {/* Readonly */} + , + + {/* Disabled */} + , + + {/* Custom locale */} + , + + {/* Max and min */} + , + + {/* 15 minute increments */} + , + + {/* Specific days/months/years */} + , + + {/* Selecting time, no date */} + , + + {/* Selecting time first, date second */} + , + + {/* Custom title */} + +
My Custom Title
+
, + + {/* Custom buttons */} + this.customDatetime = el}> + + this.confirm()}>Good to go! + this.reset()}>Reset + + , + + {/* Datetime in overlay */} + Open Datetime Modal + + + + + + ] } } ``` @@ -669,122 +414,92 @@ export class DatetimeExample { ```html ``` @@ -792,31 +507,27 @@ export default defineComponent({ ## Properties -| Property | Attribute | Description | Type | Default | -| ----------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | -| `cancelText` | `cancel-text` | The text to display on the picker's cancel button. | `string` | `'Cancel'` | -| `dayNames` | `day-names` | Full day of the week names. This can be used to provide locale names for each day in the week. Defaults to English. | `string \| string[] \| undefined` | `undefined` | -| `dayShortNames` | `day-short-names` | Short abbreviated day of the week names. This can be used to provide locale names for each day in the week. Defaults to English. Defaults to: `['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']` | `string \| string[] \| undefined` | `undefined` | -| `dayValues` | `day-values` | 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 a number, an array of numbers, or a 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. | `number \| number[] \| string \| undefined` | `undefined` | -| `disabled` | `disabled` | If `true`, the user cannot interact with the datetime. | `boolean` | `false` | -| `displayFormat` | `display-format` | 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`. | `string` | `'MMM D, YYYY'` | -| `displayTimezone` | `display-timezone` | The timezone to use for display purposes only. See [Date.prototype.toLocaleString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleString) for a list of supported timezones. If no value is provided, the component will default to displaying times in the user's local timezone. | `string \| undefined` | `undefined` | -| `doneText` | `done-text` | The text to display on the picker's "Done" button. | `string` | `'Done'` | -| `hourValues` | `hour-values` | Values used to create the list of selectable hours. By default the hour values range from `0` 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 a number, an array of numbers, or a string of comma separated numbers. | `number \| number[] \| string \| undefined` | `undefined` | -| `max` | `max` | 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. | `string \| undefined` | `undefined` | -| `min` | `min` | 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. | `string \| undefined` | `undefined` | -| `minuteValues` | `minute-values` | Values used to create the list of selectable minutes. By default the minutes range from `0` to `59`. However, to control exactly which minutes to display, the `minuteValues` input can take a number, an array of numbers, or a 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"`. | `number \| number[] \| string \| undefined` | `undefined` | -| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` | -| `monthNames` | `month-names` | Full names for each month name. This can be used to provide locale month names. Defaults to English. | `string \| string[] \| undefined` | `undefined` | -| `monthShortNames` | `month-short-names` | Short abbreviated names for each month name. This can be used to provide locale month names. Defaults to English. | `string \| string[] \| undefined` | `undefined` | -| `monthValues` | `month-values` | 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 a number, an array of numbers, or a 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`. | `number \| number[] \| string \| undefined` | `undefined` | -| `name` | `name` | The name of the control, which is submitted with the form data. | `string` | `this.inputId` | -| `pickerFormat` | `picker-format` | 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`. | `string \| undefined` | `undefined` | -| `pickerOptions` | -- | Any additional options that the picker interface can accept. See the [Picker API docs](../picker) for the picker options. | `undefined \| { columns?: PickerColumn[] \| undefined; buttons?: PickerButton[] \| undefined; cssClass?: string \| string[] \| undefined; showBackdrop?: boolean \| undefined; backdropDismiss?: boolean \| undefined; animated?: boolean \| undefined; mode?: Mode \| undefined; keyboardClose?: boolean \| undefined; id?: string \| undefined; enterAnimation?: AnimationBuilder \| undefined; leaveAnimation?: AnimationBuilder \| undefined; }` | `undefined` | -| `placeholder` | `placeholder` | The text to display when there's no date selected yet. Using lowercase to match the input attribute | `null \| string \| undefined` | `undefined` | -| `readonly` | `readonly` | If `true`, the datetime appears normal but is not interactive. | `boolean` | `false` | -| `value` | `value` | The value of the datetime as a valid ISO 8601 datetime string. | `null \| string \| undefined` | `undefined` | -| `yearValues` | `year-values` | 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 a number, 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"`. | `number \| number[] \| string \| undefined` | `undefined` | +| Property | Attribute | Description | Type | Default | +| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------- | +| `cancelText` | `cancel-text` | The text to display on the picker's cancel button. | `string` | `'Cancel'` | +| `color` | `color` | The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). | `string \| undefined` | `'primary'` | +| `dayValues` | `day-values` | 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 a number, an array of numbers, or a 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. | `number \| number[] \| string \| undefined` | `undefined` | +| `disabled` | `disabled` | If `true`, the user cannot interact with the datetime. | `boolean` | `false` | +| `doneText` | `done-text` | The text to display on the picker's "Done" button. | `string` | `'Done'` | +| `hourValues` | `hour-values` | Values used to create the list of selectable hours. By default the hour values range from `0` 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 a number, an array of numbers, or a string of comma separated numbers. | `number \| number[] \| string \| undefined` | `undefined` | +| `locale` | `locale` | The locale to use for `ion-datetime`. This impacts month and day name formatting. The `'default'` value refers to the default locale set by your device. | `string` | `'default'` | +| `max` | `max` | 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. | `string \| undefined` | `undefined` | +| `min` | `min` | 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. | `string \| undefined` | `undefined` | +| `minuteValues` | `minute-values` | Values used to create the list of selectable minutes. By default the minutes range from `0` to `59`. However, to control exactly which minutes to display, the `minuteValues` input can take a number, an array of numbers, or a 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"`. | `number \| number[] \| string \| undefined` | `undefined` | +| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` | +| `monthValues` | `month-values` | 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 a number, an array of numbers, or a 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`. | `number \| number[] \| string \| undefined` | `undefined` | +| `name` | `name` | The name of the control, which is submitted with the form data. | `string` | `this.inputId` | +| `presentation` | `presentation` | Which values you want to select. `'date'` will show a calendar picker to select the month, day, and year. `'time'` will show a time picker to select the hour, minute, and (optionally) AM/PM. `'date-time'` will show the date picker first and time picker second. `'time-date'` will show the time picker first and date picker second. | `"date" \| "date-time" \| "time" \| "time-date"` | `'date-time'` | +| `readonly` | `readonly` | If `true`, the datetime appears normal but is not interactive. | `boolean` | `false` | +| `showDefaultButtons` | `show-default-buttons` | If `true`, the default "Cancel" and "OK" buttons will be rendered at the bottom of the `ion-datetime` component. Developers can also use the `button` slot if they want to customize these buttons. If custom buttons are set in the `button` slot then the default buttons will not be rendered. | `boolean` | `false` | +| `showDefaultTitle` | `show-default-title` | If `true`, a header will be shown above the calendar picker. On `ios` mode this will include the slotted title, and on `md` mode this will include the slotted title and the selected date. | `boolean` | `false` | +| `value` | `value` | The value of the datetime as a valid ISO 8601 datetime string. | `null \| string \| undefined` | `undefined` | +| `yearValues` | `year-values` | 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 a number, 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"`. | `number \| number[] \| string \| undefined` | `undefined` | ## Events @@ -831,9 +542,37 @@ export default defineComponent({ ## Methods -### `open() => Promise` +### `cancel(closeOverlay?: boolean) => Promise` -Opens the datetime overlay. +Emits the ionCancel event and +optionally closes the popover +or modal that the datetime was +presented in. + +#### Returns + +Type: `Promise` + + + +### `confirm(closeOverlay?: boolean) => Promise` + +Confirms the selected datetime value, updates the +`value` property, and optionally closes the popover +or modal that the datetime was presented in. + +#### Returns + +Type: `Promise` + + + +### `reset(value?: string | undefined) => Promise` + +Resets the internal state of the datetime +but does not update the value. Passing a value +ISO-8601 string will reset the state of +te component to the provided date. #### Returns @@ -842,25 +581,52 @@ Type: `Promise` -## Shadow Parts +## Slots -| Part | Description | -| --------------- | -------------------------------- | -| `"placeholder"` | The placeholder of the datetime. | -| `"text"` | The value of the datetime. | +| Slot | Description | +| ----------- | ---------------------------- | +| `"buttons"` | The buttons in the datetime. | +| `"title"` | The title of the datetime. | ## CSS Custom Properties -| Name | Description | -| --------------------- | ----------------------------------------------------------------------------------------------------------- | -| `--padding-bottom` | Bottom padding of the datetime | -| `--padding-end` | Right padding if direction is left-to-right, and left padding if direction is right-to-left of the datetime | -| `--padding-start` | Left padding if direction is left-to-right, and right padding if direction is right-to-left of the datetime | -| `--padding-top` | Top padding of the datetime | -| `--placeholder-color` | Color of the datetime placeholder | +| Name | Description | +| ------------------ | --------------------------------------------------------------- | +| `--background` | The primary background of the datetime component. | +| `--background-rgb` | The primary background of the datetime component in RGB format. | +| `--title-color` | The text color of the title. | +## Dependencies + +### Depends on + +- [ion-buttons](../buttons) +- [ion-button](../button) +- [ion-item](../item) +- [ion-label](../label) +- ion-icon +- [ion-segment](../segment) +- [ion-segment-button](../segment-button) + +### Graph +```mermaid +graph TD; + ion-datetime --> ion-buttons + ion-datetime --> ion-button + ion-datetime --> ion-item + ion-datetime --> ion-label + ion-datetime --> ion-icon + ion-datetime --> ion-segment + ion-datetime --> ion-segment-button + ion-button --> ion-ripple-effect + ion-item --> ion-icon + ion-item --> ion-ripple-effect + ion-segment-button --> ion-ripple-effect + style ion-datetime fill:#f9f,stroke:#333,stroke-width:4px +``` + ---------------------------------------------- *Built with [StencilJS](https://stenciljs.com/)* diff --git a/core/src/components/datetime/test/a11y/datetime.spec.ts b/core/src/components/datetime/test/a11y/datetime.spec.ts deleted file mode 100644 index 2007e77ce3..0000000000 --- a/core/src/components/datetime/test/a11y/datetime.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { newSpecPage } from '@stencil/core/testing'; -import { Datetime } from '../../datetime'; -import { Item } from '../../../item/item'; -import { Label } from '../../../label/label'; - -describe('Datetime a11y', () => { - it('does not set a default aria-labelledby when there is not a neighboring ion-label', async () => { - const page = await newSpecPage({ - components: [Datetime, Item, Label], - html: `` - }) - - const ariaLabelledBy = page.root.getAttribute('aria-labelledby'); - expect(ariaLabelledBy).toBe(null); - }); - - it('set a default aria-labelledby when a neighboring ion-label exists', async () => { - const page = await newSpecPage({ - components: [Datetime, Item, Label], - html: ` - A11y Test - - ` - }) - - const label = page.body.querySelector('ion-label'); - const ariaLabelledBy = page.body.querySelector('ion-datetime').getAttribute('aria-labelledby'); - expect(ariaLabelledBy).toBe(label.id); - }); -}); diff --git a/core/src/components/datetime/test/basic/e2e.ts b/core/src/components/datetime/test/basic/e2e.ts deleted file mode 100644 index 36240b4a02..0000000000 --- a/core/src/components/datetime/test/basic/e2e.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { newE2EPage } from '@stencil/core/testing'; - -const getActiveElementText = async (page) => { - const activeElement = await page.evaluateHandle(() => document.activeElement); - return await page.evaluate(el => el && el.textContent, activeElement); -} - -const getActiveElementClass = async (page) => { - const activeElement = await page.evaluateHandle(() => document.activeElement); - return await page.evaluate(el => el && el.className, activeElement); -} - -test('datetime/picker: focus trap', async () => { - const page = await newE2EPage({ url: '/src/components/datetime/test/basic?ionic:_testing=true' }); - await page.click('#datetime-part'); - await page.waitForSelector('#datetime-part'); - - let datetime = await page.find('ion-datetime'); - - expect(datetime).not.toBe(null); - - // TODO fix - await page.waitForTimeout(250); - - await page.keyboard.press('Tab'); - - const activeElementText = await getActiveElementText(page); - expect(activeElementText).toEqual('Cancel'); - - await page.keyboard.down('Shift'); - await page.keyboard.press('Tab'); - await page.keyboard.up('Shift'); - - const activeElementClass = await getActiveElementClass(page); - expect(activeElementClass).toEqual('picker-opt'); - - await page.keyboard.press('Tab'); - - const activeElementTextThree = await getActiveElementText(page); - expect(activeElementTextThree).toEqual('Cancel'); -}); - -test('datetime: basic', async () => { - const page = await newE2EPage({ - url: '/src/components/datetime/test/basic?ionic:_testing=true' - }); - - let compare = await page.compareScreenshot(); - expect(compare).toMatchScreenshot(); - - const datetime = await page.find('#customPickerOptions'); - await datetime.waitForVisible(); - await datetime.click(); - - const picker = await page.find('ion-picker'); - await picker.waitForVisible(); - await page.waitForTimeout(250); - - compare = await page.compareScreenshot('should open custom picker'); - expect(compare).toMatchScreenshot(); -}); - -test('datetime: basic-rtl', async () => { - const page = await newE2EPage({ - url: '/src/components/datetime/test/basic?ionic:_testing=true&rtl=true' - }); - - const datetime = await page.find('#customPickerOptions'); - await datetime.click(); - - const picker = await page.find('ion-picker'); - await picker.waitForVisible(); - await page.waitForTimeout(250); - - const compare = await page.compareScreenshot('should open custom picker'); - expect(compare).toMatchScreenshot(); -}); diff --git a/core/src/components/datetime/test/basic/index.html b/core/src/components/datetime/test/basic/index.html index d7e93c1b17..9aa5b92b03 100644 --- a/core/src/components/datetime/test/basic/index.html +++ b/core/src/components/datetime/test/basic/index.html @@ -1,277 +1,170 @@ - - - - Datetime - Basic - - - - - - - - - - - - - Datetime - Basic - - - (disabled) - - - - - - - - - Default - - - - - Default with floating label - - - - - Placeholder with floating label - - - - - Max - - - - - MMMM - - - - - MM DD YY - - - - - Disabled - - - - - YYYY - - - - - Multiple - - - - - - - MMMM YY - - - - - MM/DD/YYYY - - - - - MM/DD/YYYY - - - - - DDD. MMM DD, YY (custom locale) - - - - - DDD. MMM DD, YY (English/French switch) - - Language Selected: en - - - - - D MMM YYYY H:mm - - - - - DDDD MMM D, YYYY - - - - - HH:mm A - - - - - HH:mm (initial value 00:00) - - - - - h:mm a - - - - - h:mm A - - - - - hh:mm A (15 min steps) - - - - - YYYY MMM DD hh:mm A - - - - - - Leap years, summer months - - - - - Specific days/months/years - - - - - - - myDate - - - - - - - Display UTC 00:00 in Local Timezone (default behavior) - - - - - Display UTC 00:00 in UTC (display-timezone = 'utc') - - - - - Display UTC 00:00 in US Pacific Time (display-timezone = 'America/Los_Angeles') - - - - - - - HH:mm:ss - - - - - No display-format - - - - - - - + + + Datetime - Basic + + + + + + + + + + + Datetime - Basic + + + Dark Mode + + + + + + +
+
+

Inline

+ +
- - - + const createModal = () => { + // create component to open + const element = document.createElement('div'); + element.innerHTML = ` + + Select Date + + `; + // present the modal + const modalElement = Object.assign(document.createElement('ion-modal'), { + component: element + }); + document.body.appendChild(modalElement); + return modalElement; + } + + + diff --git a/core/src/components/datetime/test/color/e2e.ts b/core/src/components/datetime/test/color/e2e.ts new file mode 100644 index 0000000000..dde47d8630 --- /dev/null +++ b/core/src/components/datetime/test/color/e2e.ts @@ -0,0 +1,33 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('color', async () => { + const page = await newE2EPage({ + url: '/src/components/datetime/test/color?ionic:_testing=true' + }); + + const screenshotCompares = []; + + screenshotCompares.push(await page.compareScreenshot()); + + const colorSelect = await page.find('ion-select'); + const darkModeToggle = await page.find('ion-checkbox'); + + darkModeToggle.setProperty('checked', true); + await page.waitForChanges(); + screenshotCompares.push(await page.compareScreenshot()); + + darkModeToggle.setProperty('checked', false); + colorSelect.setProperty('value', 'danger'); + await page.waitForChanges(); + + screenshotCompares.push(await page.compareScreenshot()); + + darkModeToggle.setProperty('checked', true); + await page.waitForChanges(); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); diff --git a/core/src/components/datetime/test/color/index.html b/core/src/components/datetime/test/color/index.html new file mode 100644 index 0000000000..03a47e1d2c --- /dev/null +++ b/core/src/components/datetime/test/color/index.html @@ -0,0 +1,249 @@ + + + + + Datetime - Color + + + + + + + + + + + + Datetime - Color + + Dark Mode + + + + + Color + + Primary + Secondary + Tertiary + Success + Warning + Danger + + + + + +
+
+

Default

+ +
+ +
+

Custom

+ + + Reset + Confirm + + +
+
+
+ +
+ + diff --git a/core/src/components/datetime/test/comparison.spec.ts b/core/src/components/datetime/test/comparison.spec.ts new file mode 100644 index 0000000000..dd1b65df74 --- /dev/null +++ b/core/src/components/datetime/test/comparison.spec.ts @@ -0,0 +1,51 @@ +import { + isSameDay, + isBefore, + isAfter +} from '../utils/comparison'; + +describe('isSameDay()', () => { + it('should return correct results for month, day, and year', () => { + const reference = { month: 1, day: 1, year: 2021 } + + expect(isSameDay(reference, { month: 1, day: 1, year: 2021 })).toEqual(true); + expect(isSameDay(reference, { month: 2, day: 1, year: 2021 })).toEqual(false); + expect(isSameDay(reference, { month: 1, day: 2, year: 2021 })).toEqual(false); + expect(isSameDay(reference, { month: 1, day: 1, year: 2022 })).toEqual(false); + expect(isSameDay(reference, { month: 0, day: 0, year: 0 })).toEqual(false); + expect(isSameDay(reference, { month: null, day: null, year: null })).toEqual(false); + }) +}) + +describe('isBefore()', () => { + it('should return correct results for month, day, and year', () => { + const reference = { month: 1, day: 1, year: 2021 } + + expect(isBefore(reference, { month: 1, day: 1, year: 2021 })).toEqual(false); + expect(isBefore(reference, { month: 2, day: 1, year: 2021 })).toEqual(true); + expect(isBefore(reference, { month: 1, day: 2, year: 2021 })).toEqual(true); + expect(isBefore(reference, { month: 1, day: 1, year: 2022 })).toEqual(true); + expect(isBefore(reference, { month: 1, day: 1, year: 2020 })).toEqual(false); + expect(isBefore(reference, { month: 0, day: 0, year: 0 })).toEqual(false); + expect(isBefore(reference, { month: null, day: null, year: null })).toEqual(false); + }) +}) + +describe('isAfter()', () => { + it('should return correct results for month, day, and year', () => { + const reference = { month: 2, day: 2, year: 2021 } + + expect(isAfter(reference, { month: 2, day: 2, year: 2021 })).toEqual(false); + expect(isAfter(reference, { month: 2, day: 1, year: 2021 })).toEqual(true); + expect(isAfter(reference, { month: 1, day: 2, year: 2021 })).toEqual(true); + expect(isAfter(reference, { month: 1, day: 1, year: 2020 })).toEqual(true); + expect(isAfter(reference, { month: 1, day: 1, year: 2022 })).toEqual(false); + expect(isAfter(reference, { month: 0, day: 0, year: 0 })).toEqual(true); + + /** + * 2021 > undefined === false + * 2021 > null === true + */ + expect(isAfter(reference, { month: null, day: null, year: null })).toEqual(true); + }) +}) diff --git a/core/src/components/datetime/test/data.spec.ts b/core/src/components/datetime/test/data.spec.ts new file mode 100644 index 0000000000..03fdcca961 --- /dev/null +++ b/core/src/components/datetime/test/data.spec.ts @@ -0,0 +1,215 @@ +import { + generateMonths, + getDaysOfWeek, + generateTime +} from '../utils/data'; + +describe('generateMonths()', () => { + it('should generate correct month data', () => { + expect(generateMonths({ month: 5, year: 2021, day: 1 })).toEqual([ + { month: 4, year: 2021, day: 1 }, + { month: 5, year: 2021, day: 1 }, + { month: 6, year: 2021, day: 1 } + ]); + }); +}); + +describe('getDaysOfWeek()', () => { + it('should return English short names given a locale and mode', () => { + expect(getDaysOfWeek('en-US', 'ios')).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); + }); + + it('should return English narrow names given a locale and mode', () => { + expect(getDaysOfWeek('en-US', 'md')).toEqual(['S', 'M', 'T', 'W', 'T', 'F', 'S']); + }); + + it('should return Spanish short names given a locale and mode', () => { + expect(getDaysOfWeek('es-ES', 'ios')).toEqual(['dom', 'lun', 'mar', 'mié', 'jue', 'vie', 'sáb']); + }); + + it('should return Spanish narrow names given a locale and mode', () => { + expect(getDaysOfWeek('es-ES', 'md')).toEqual(['D', 'L', 'M', 'X', 'J', 'V', 'S']); + }); +}) + +describe('generateTime()', () => { + it('should not filter and hours/minutes when no bounds set', () => { + const today = { + day: 19, + month: 5, + year: 2021, + hour: 5, + minute: 43 + } + const { hours, minutes, use24Hour } = generateTime('en-US', today); + + expect(hours.length).toEqual(12); + expect(minutes.length).toEqual(60); + expect(use24Hour).toEqual(false); + }); + it('should filter according to min', () => { + const today = { + day: 19, + month: 5, + year: 2021, + hour: 5, + minute: 43 + } + const min = { + day: 19, + month: 5, + year: 2021, + hour: 2, + minute: 40 + } + const { hours, minutes, use24Hour } = generateTime('en-US', today, min); + + expect(hours.length).toEqual(11); + expect(minutes.length).toEqual(20); + expect(use24Hour).toEqual(false); + }) + it('should not filter according to min if not on reference day', () => { + const today = { + day: 20, + month: 5, + year: 2021, + hour: 5, + minute: 43 + } + const min = { + day: 19, + month: 5, + year: 2021, + hour: 2, + minute: 40 + } + const { hours, minutes, use24Hour } = generateTime('en-US', today, min); + + expect(hours.length).toEqual(12); + expect(minutes.length).toEqual(60); + expect(use24Hour).toEqual(false); + }) + it('should filter according to max', () => { + const today = { + day: 19, + month: 5, + year: 2021, + hour: 5, + minute: 43 + } + const max = { + day: 19, + month: 5, + year: 2021, + hour: 7, + minute: 44 + } + const { hours, minutes, use24Hour } = generateTime('en-US', today, undefined, max); + + expect(hours.length).toEqual(7); + expect(minutes.length).toEqual(45); + expect(use24Hour).toEqual(false); + }) + it('should not filter according to min if not on reference day', () => { + const today = { + day: 20, + month: 5, + year: 2021, + hour: 5, + minute: 43 + } + const max = { + day: 21, + month: 5, + year: 2021, + hour: 2, + minute: 40 + } + const { hours, minutes, use24Hour } = generateTime('en-US', today, undefined, max); + + expect(hours.length).toEqual(12); + expect(minutes.length).toEqual(60); + expect(use24Hour).toEqual(false); + }) + it('should return no values for a day less than the min', () => { + const today = { + day: 20, + month: 5, + year: 2021, + hour: 5, + minute: 43 + } + const min = { + day: 21, + month: 5, + year: 2021, + hour: 2, + minute: 40 + } + const { hours, minutes, use24Hour } = generateTime('en-US', today, min); + + expect(hours.length).toEqual(0); + expect(minutes.length).toEqual(0); + expect(use24Hour).toEqual(false); + }) + it('should return no values for a day greater than the max', () => { + const today = { + day: 22, + month: 5, + year: 2021, + hour: 5, + minute: 43 + } + const max = { + day: 21, + month: 5, + year: 2021, + hour: 2, + minute: 40 + } + const { hours, minutes, use24Hour } = generateTime('en-US', today, undefined, max); + + expect(hours.length).toEqual(0); + expect(minutes.length).toEqual(0); + expect(use24Hour).toEqual(false); + }) + it('should allow all hours and minutes if not set in min/max', () => { + const today = { + day: 22, + month: 5, + year: 2021, + hour: 5, + minute: 43 + } + const min = { + day: 22, + month: 5, + year: 2021 + } + const max = { + day: 22, + month: 5, + year: 2021 + } + + const { hours, minutes, use24Hour } = generateTime('en-US', today, min, max); + + expect(hours.length).toEqual(12); + expect(minutes.length).toEqual(60); + expect(use24Hour).toEqual(false); + }) + it('should allow certain hours and minutes based on minuteValues and hourValues', () => { + const today = { + day: 22, + month: 5, + year: 2021, + hour: 5, + minute: 43 + } + + const { hours, minutes, use24Hour } = generateTime('en-US', today, undefined, undefined, [1,2,3], [10,15,20]); + + expect(hours).toStrictEqual([1,2,3]); + expect(minutes).toStrictEqual([10,15,20]); + }) +}) diff --git a/core/src/components/datetime/test/datetime.spec.ts b/core/src/components/datetime/test/datetime.spec.ts deleted file mode 100644 index 84a2ce49c3..0000000000 --- a/core/src/components/datetime/test/datetime.spec.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { DatetimeData, daysInMonth, getDateValue, getDateTime, renderDatetime } from '../datetime-util'; - -describe('Datetime', () => { - describe('getDateValue()', () => { - it('it should return the date value for the current day', () => { - const today = new Date(); - - const dayValue = getDateValue({}, 'DD'); - const monthvalue = getDateValue({}, 'MM'); - const yearValue = getDateValue({}, 'YYYY'); - - expect(dayValue).toEqual(today.getUTCDate()); - expect(monthvalue).toEqual(today.getUTCMonth() + 1); - expect(yearValue).toEqual(today.getUTCFullYear()); - }); - - it('it should return the date value for a given day', () => { - const date = new Date('15 October 1995'); - const dateTimeData: DatetimeData = { - year: date.getFullYear(), - month: date.getMonth() + 1, - day: date.getDate() - } - - const dayValue = getDateValue(dateTimeData, 'DD'); - const monthvalue = getDateValue(dateTimeData, 'MM'); - const yearValue = getDateValue(dateTimeData, 'YYYY'); - - expect(dayValue).toEqual(date.getDate()); - expect(monthvalue).toEqual(date.getMonth() + 1); - expect(yearValue).toEqual(date.getFullYear()); - }); - - it('it should return the date value for a given time', () => { - const dateTimeData: DatetimeData = { - hour: 2, - minute: 23, - tzOffset: 0 - }; - - const hourValue = getDateValue(dateTimeData, 'hh'); - const minuteValue = getDateValue(dateTimeData, 'mm'); - const ampmValue = getDateValue(dateTimeData, 'A'); - - expect(hourValue).toEqual(2); - expect(minuteValue).toEqual(23); - expect(ampmValue).toEqual("am"); - }); - - it('it should return the date value for a given time after 12', () => { - const dateTimeData: DatetimeData = { - hour: 16, - minute: 47, - tzOffset: 0 - }; - - const hourValue = getDateValue(dateTimeData, 'hh'); - const minuteValue = getDateValue(dateTimeData, 'mm'); - const ampmValue = getDateValue(dateTimeData, 'a'); - - expect(hourValue).toEqual(4); - expect(minuteValue).toEqual(47); - expect(ampmValue).toEqual("pm"); - }); - }); - - describe('getDateTime()', () => { - it('should format a datetime string according to the local timezone', () => { - - const dateStringTests = [ - { expectedHourUTC: 12, input: `2019-03-02T12:08:06.601-00:00`, expectedOutput: `2019-03-02T%HOUR%:08:06.601Z` }, - { expectedHourUTC: 12, input: `2019-11-02T12:08:06.601-00:00`, expectedOutput: `2019-11-02T%HOUR%:08:06.601Z` }, - { expectedHourUTC: 8, input: `1994-12-15T13:47:20.789+05:00`, expectedOutput: `1994-12-15T%HOUR%:47:20.789Z` }, - { expectedHourUTC: 18, input: `1994-12-15T13:47:20.789-05:00`, expectedOutput: `1994-12-15T%HOUR%:47:20.789Z` }, - { expectedHourUTC: 9, input: `2019-02-14T09:00:00.000Z`, expectedOutput: `2019-02-14T%HOUR%:00:00.000Z` } - ]; - - dateStringTests.forEach(test => { - const convertToLocal = getDateTime(test.input); - - const timeZoneOffset = convertToLocal.getTimezoneOffset() / 60; - const expectedDateString = test.expectedOutput.replace('%HOUR%', padNumber(test.expectedHourUTC - timeZoneOffset)); - - expect(convertToLocal.toISOString()).toEqual(expectedDateString); - }); - }); - - it('should format a date string and not get affected by the timezone offset', () => { - - const dateStringTests = [ - { input: '2019-03-20', expectedOutput: '2019-03-20' }, - { input: '1994-04-15', expectedOutput: '1994-04-15' }, - { input: '2008-09-02', expectedOutput: '2008-09-02' }, - { input: '1995-02', expectedOutput: '1995-02' }, - { input: '1994-03-14', expectedOutput: '1994-03-14' }, - { input: '9 01:47', expectedOutput: '09-01T01:47' } - ]; - - dateStringTests.forEach(test => { - const convertToLocal = getDateTime(test.input); - expect(convertToLocal.toISOString()).toContain(test.expectedOutput); - }); - }); - - it('should format a datetime string using provided timezone', () => { - const dateStringTests = [ - { displayTimezone: 'utc', input: `2019-03-02T12:00:00.000Z`, expectedOutput: `2019-03-02T12:00:00.000Z` }, - { displayTimezone: 'America/New_York', input: `2019-03-02T12:00:00.000Z`, expectedOutput: `2019-03-02T07:00:00.000Z` }, - { displayTimezone: 'Asia/Tokyo', input: `2019-03-02T12:00:00.000Z`, expectedOutput: `2019-03-02T21:00:00.000Z` }, - ]; - - dateStringTests.forEach(test => { - const convertToLocal = getDateTime(test.input, test.displayTimezone); - expect(convertToLocal.toISOString()).toEqual(test.expectedOutput); - }); - }); - - it('should default to today for null and undefined cases', () => { - const today = new Date(); - const todayString = renderDatetime('YYYY-MM-DD', { year: today.getFullYear(), month: today.getMonth() + 1, day: today.getDate() } ) - - const convertToLocalUndefined = getDateTime(undefined); - expect(convertToLocalUndefined.toISOString()).toContain(todayString); - - const convertToLocalNull = getDateTime(null); - expect(convertToLocalNull.toISOString()).toContain(todayString); - }); - }); - - describe('daysInMonth()', () => { - it('should return correct days in month for month and year', () => { - expect(daysInMonth(1, 2019)).toBe(31); - expect(daysInMonth(2, 2019)).toBe(28); - expect(daysInMonth(3, 2019)).toBe(31); - expect(daysInMonth(4, 2019)).toBe(30); - expect(daysInMonth(5, 2019)).toBe(31); - expect(daysInMonth(6, 2019)).toBe(30); - expect(daysInMonth(7, 2019)).toBe(31); - expect(daysInMonth(8, 2019)).toBe(31); - expect(daysInMonth(9, 2019)).toBe(30); - expect(daysInMonth(10, 2019)).toBe(31); - expect(daysInMonth(11, 2019)).toBe(30); - expect(daysInMonth(12, 2019)).toBe(31); - expect(daysInMonth(2, 2020)).toBe(29); - }); - }); -}); - -function padNumber(number: number, totalLength: number = 2): string { - return number.toString().padStart(totalLength, '0'); -} diff --git a/core/src/components/datetime/test/demo/index.html b/core/src/components/datetime/test/demo/index.html new file mode 100644 index 0000000000..b187b7890b --- /dev/null +++ b/core/src/components/datetime/test/demo/index.html @@ -0,0 +1,294 @@ + + + + + Datetime - Basic + + + + + + + + + + + + + + + + + Options + + + + Dark Mode + + + + iOS Mode + + + MD Mode + + + + Show Default Title + + + + + Show Default Buttons + + + + + Locale + + + + + Color + + Primary + Secondary + Tertiary + Success + Warning + Danger + + + + + + + + + + + diff --git a/core/src/components/datetime/test/format.spec.ts b/core/src/components/datetime/test/format.spec.ts new file mode 100644 index 0000000000..f1ea01d00d --- /dev/null +++ b/core/src/components/datetime/test/format.spec.ts @@ -0,0 +1,69 @@ +import { + generateDayAriaLabel, + getMonthAndDay, + getFormattedHour, + addTimePadding, + getMonthAndYear +} from '../utils/format'; + +describe('generateDayAriaLabel()', () => { + it('should return Wednesday, May 12', () => { + const reference = { month: 5, day: 12, year: 2021 }; + + expect(generateDayAriaLabel('en-US', false, reference)).toEqual('Wednesday, May 12'); + }); + it('should return Today, Wednesday, May 12', () => { + const reference = { month: 5, day: 12, year: 2021 }; + + expect(generateDayAriaLabel('en-US', true, reference)).toEqual('Today, Wednesday, May 12'); + }); + it('should return Saturday, May 1', () => { + const reference = { month: 5, day: 1, year: 2021 }; + + expect(generateDayAriaLabel('en-US', false, reference)).toEqual('Saturday, May 1'); + }); + it('should return Monday, May 31', () => { + const reference = { month: 5, day: 31, year: 2021 }; + + expect(generateDayAriaLabel('en-US', false, reference)).toEqual('Monday, May 31'); + }); +}); + +describe('getMonthAndDay()', () => { + it('should return Tue, May 11', () => { + expect(getMonthAndDay('en-US', { month: 5, day: 11, year: 2021 })).toEqual('Tue, May 11'); + }); + + it('should return mar, 11 may', () => { + expect(getMonthAndDay('es-ES', { month: 5, day: 11, year: 2021 })).toEqual('mar, 11 may'); + }); +}) + +describe('getFormattedHour()', () => { + it('should only add padding if using 24 hour time', () => { + expect(getFormattedHour(0, true)).toEqual('00'); + expect(getFormattedHour(0, false)).toEqual('0'); + + expect(getFormattedHour(10, true)).toEqual('10'); + expect(getFormattedHour(10, false)).toEqual('10'); + }) +}); + +describe('addTimePadding()', () => { + it('should add correct amount of padding', () => { + expect(addTimePadding(0)).toEqual('00'); + expect(addTimePadding(9)).toEqual('09'); + expect(addTimePadding(10)).toEqual('10'); + expect(addTimePadding(100)).toEqual('100'); + }) +}); + +describe('getMonthAndYear()', () => { + it('should return May 2021', () => { + expect(getMonthAndYear('en-US', { month: 5, day: 11, year: 2021 })).toEqual('May 2021'); + }); + + it('should return mar, 11 may', () => { + expect(getMonthAndYear('es-ES', { month: 5, day: 11, year: 2021 })).toEqual('mayo de 2021'); + }); +}) diff --git a/core/src/components/datetime/test/helpers.spec.ts b/core/src/components/datetime/test/helpers.spec.ts new file mode 100644 index 0000000000..18e81eabfa --- /dev/null +++ b/core/src/components/datetime/test/helpers.spec.ts @@ -0,0 +1,47 @@ +import { + isLeapYear, + getNumDaysInMonth, + is24Hour +} from '../utils/helpers'; + +describe('daysInMonth()', () => { + it('should return correct days in month for month and year', () => { + expect(getNumDaysInMonth(1, 2019)).toBe(31); + expect(getNumDaysInMonth(2, 2019)).toBe(28); + expect(getNumDaysInMonth(3, 2019)).toBe(31); + expect(getNumDaysInMonth(4, 2019)).toBe(30); + expect(getNumDaysInMonth(5, 2019)).toBe(31); + expect(getNumDaysInMonth(6, 2019)).toBe(30); + expect(getNumDaysInMonth(7, 2019)).toBe(31); + expect(getNumDaysInMonth(8, 2019)).toBe(31); + expect(getNumDaysInMonth(9, 2019)).toBe(30); + expect(getNumDaysInMonth(10, 2019)).toBe(31); + expect(getNumDaysInMonth(11, 2019)).toBe(30); + expect(getNumDaysInMonth(12, 2019)).toBe(31); + expect(getNumDaysInMonth(2, 2020)).toBe(29); + + expect(getNumDaysInMonth(2, 2021)).toBe(28); + expect(getNumDaysInMonth(2, 1900)).toBe(28); + expect(getNumDaysInMonth(2, 1800)).toBe(28); + expect(getNumDaysInMonth(2, 2400)).toBe(29); + }); +}); + +describe('isLeapYear()', () => { + it('should return true if year is leapyear', () => { + expect(isLeapYear(2096)).toBe(true); + expect(isLeapYear(2021)).toBe(false); + expect(isLeapYear(2012)).toBe(true); + + expect(isLeapYear(2000)).toBe(true); + expect(isLeapYear(1900)).toBe(false); + expect(isLeapYear(1800)).toBe(false); + }) +}) + +describe('is24Hour()', () => { + it('should return true if the locale uses 24 hour time', () => { + expect(is24Hour('en-US')).toBe(false); + expect(is24Hour('en-GB')).toBe(true); + }) +}) diff --git a/core/src/components/datetime/test/locale/e2e.ts b/core/src/components/datetime/test/locale/e2e.ts new file mode 100644 index 0000000000..6b16e6b7f7 --- /dev/null +++ b/core/src/components/datetime/test/locale/e2e.ts @@ -0,0 +1,21 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('locale', async () => { + const page = await newE2EPage({ + url: '/src/components/datetime/test/locale?ionic:_testing=true' + }); + + const screenshotCompares = []; + const datetime = await page.find('ion-datetime'); + + screenshotCompares.push(await page.compareScreenshot()); + + datetime.setProperty('locale', 'es-ES'); + await page.waitForChanges(); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); diff --git a/core/src/components/datetime/test/locale/index.html b/core/src/components/datetime/test/locale/index.html new file mode 100644 index 0000000000..311a062bb4 --- /dev/null +++ b/core/src/components/datetime/test/locale/index.html @@ -0,0 +1,61 @@ + + + + + Datetime - Locale + + + + + + + + + + + + Datetime - Locale + + + +
+
+

Default

+ +
+ + + + diff --git a/core/src/components/datetime/test/manipulation.spec.ts b/core/src/components/datetime/test/manipulation.spec.ts new file mode 100644 index 0000000000..56661b1375 --- /dev/null +++ b/core/src/components/datetime/test/manipulation.spec.ts @@ -0,0 +1,390 @@ +import { + getPreviousMonth, + getNextMonth, + getPreviousDay, + getNextDay, + getPreviousWeek, + getNextWeek, + getEndOfWeek, + getStartOfWeek, + convert12HourTo24Hour, + getInternalHourValue, + calculateHourFromAMPM, + subtractDays, + addDays +} from '../utils/manipulation'; + +describe('addDays()', () => { + it('should correctly add days', () => { + expect(addDays({ + day: 1, + month: 1, + year: 2021 + }, 31)).toEqual({ + day: 1, + month: 2, + year: 2021 + }) + + expect(addDays({ + day: 31, + month: 12, + year: 2021 + }, 1)).toEqual({ + day: 1, + month: 1, + year: 2022 + }) + }) +}) + +describe('subtractDays()', () => { + it('should correctly subtract days', () => { + expect(subtractDays({ + day: 1, + month: 1, + year: 2021 + }, 1)).toEqual({ + day: 31, + month: 12, + year: 2020 + }) + + expect(subtractDays({ + day: 1, + month: 2, + year: 2021 + }, 31)).toEqual({ + day: 1, + month: 1, + year: 2021 + }) + }) +}) + +describe('getInternalHourValue()',() => { + it('should correctly get the internal hour value', () => { + expect(getInternalHourValue(12, true)).toEqual(12); + expect(getInternalHourValue(12, true)).toEqual(12); + + expect(getInternalHourValue(12, false, 'am')).toEqual(0); + expect(getInternalHourValue(12, false, 'pm')).toEqual(12); + + expect(getInternalHourValue(1, true)).toEqual(1); + expect(getInternalHourValue(1, true)).toEqual(1); + + expect(getInternalHourValue(1, false, 'am')).toEqual(1); + expect(getInternalHourValue(1, false, 'pm')).toEqual(13); + }); +}); + +describe('calculateHourFromAMPM()', () => { + it('should correctly convert from AM to PM', () => { + expect(calculateHourFromAMPM({ hour: 12, ampm: 'am' }, 'pm')).toEqual(12); + expect(calculateHourFromAMPM({ hour: 1, ampm: 'am' }, 'pm')).toEqual(13); + expect(calculateHourFromAMPM({ hour: 2, ampm: 'am' }, 'pm')).toEqual(14); + expect(calculateHourFromAMPM({ hour: 3, ampm: 'am' }, 'pm')).toEqual(15); + expect(calculateHourFromAMPM({ hour: 4, ampm: 'am' }, 'pm')).toEqual(16); + expect(calculateHourFromAMPM({ hour: 5, ampm: 'am' }, 'pm')).toEqual(17); + expect(calculateHourFromAMPM({ hour: 6, ampm: 'am' }, 'pm')).toEqual(18); + expect(calculateHourFromAMPM({ hour: 7, ampm: 'am' }, 'pm')).toEqual(19); + expect(calculateHourFromAMPM({ hour: 8, ampm: 'am' }, 'pm')).toEqual(20); + expect(calculateHourFromAMPM({ hour: 9, ampm: 'am' }, 'pm')).toEqual(21); + expect(calculateHourFromAMPM({ hour: 10, ampm: 'am' }, 'pm')).toEqual(22); + expect(calculateHourFromAMPM({ hour: 11, ampm: 'am' }, 'pm')).toEqual(23); + + expect(calculateHourFromAMPM({ hour: 13, ampm: 'pm' }, 'am')).toEqual(1); + expect(calculateHourFromAMPM({ hour: 14, ampm: 'pm' }, 'am')).toEqual(2); + expect(calculateHourFromAMPM({ hour: 15, ampm: 'pm' }, 'am')).toEqual(3); + expect(calculateHourFromAMPM({ hour: 16, ampm: 'pm' }, 'am')).toEqual(4); + expect(calculateHourFromAMPM({ hour: 17, ampm: 'pm' }, 'am')).toEqual(5); + expect(calculateHourFromAMPM({ hour: 18, ampm: 'pm' }, 'am')).toEqual(6); + expect(calculateHourFromAMPM({ hour: 19, ampm: 'pm' }, 'am')).toEqual(7); + expect(calculateHourFromAMPM({ hour: 20, ampm: 'pm' }, 'am')).toEqual(8); + expect(calculateHourFromAMPM({ hour: 21, ampm: 'pm' }, 'am')).toEqual(9); + expect(calculateHourFromAMPM({ hour: 22, ampm: 'pm' }, 'am')).toEqual(10); + expect(calculateHourFromAMPM({ hour: 23, ampm: 'pm' }, 'am')).toEqual(11); + expect(calculateHourFromAMPM({ hour: 0, ampm: 'pm' }, 'am')).toEqual(12); + }) +}); + + +describe('convert12HourTo24Hour()', () => { + it('should correctly convert 12 hour to 24 hour', () => { + expect(convert12HourTo24Hour(12, 'am')).toEqual(0); + expect(convert12HourTo24Hour(1, 'am')).toEqual(1); + expect(convert12HourTo24Hour(2, 'am')).toEqual(2); + expect(convert12HourTo24Hour(3, 'am')).toEqual(3); + expect(convert12HourTo24Hour(4, 'am')).toEqual(4); + expect(convert12HourTo24Hour(5, 'am')).toEqual(5); + expect(convert12HourTo24Hour(6, 'am')).toEqual(6); + expect(convert12HourTo24Hour(7, 'am')).toEqual(7); + expect(convert12HourTo24Hour(8, 'am')).toEqual(8); + expect(convert12HourTo24Hour(9, 'am')).toEqual(9); + expect(convert12HourTo24Hour(10, 'am')).toEqual(10); + expect(convert12HourTo24Hour(11, 'am')).toEqual(11); + + expect(convert12HourTo24Hour(12, 'pm')).toEqual(12); + expect(convert12HourTo24Hour(1, 'pm')).toEqual(13); + expect(convert12HourTo24Hour(2, 'pm')).toEqual(14); + expect(convert12HourTo24Hour(3, 'pm')).toEqual(15); + expect(convert12HourTo24Hour(4, 'pm')).toEqual(16); + expect(convert12HourTo24Hour(5, 'pm')).toEqual(17); + expect(convert12HourTo24Hour(6, 'pm')).toEqual(18); + expect(convert12HourTo24Hour(7, 'pm')).toEqual(19); + expect(convert12HourTo24Hour(8, 'pm')).toEqual(20); + expect(convert12HourTo24Hour(9, 'pm')).toEqual(21); + expect(convert12HourTo24Hour(10, 'pm')).toEqual(22); + expect(convert12HourTo24Hour(11, 'pm')).toEqual(23); + }) +}) + +describe('getStartOfWeek()', () => { + it('should correctly return the start of the week', () => { + expect(getStartOfWeek({ + month: 5, + day: 17, + year: 2021, + dayOfWeek: 1 + })).toEqual({ + month: 5, + day: 16, + year: 2021 + }); + + expect(getStartOfWeek({ + month: 5, + day: 1, + year: 2021, + dayOfWeek: 6 + })).toEqual({ + month: 4, + day: 25, + year: 2021, + }); + + expect(getStartOfWeek({ + month: 1, + day: 2, + year: 2021, + dayOfWeek: 6 + })).toEqual({ + month: 12, + day: 27, + year: 2020 + }); + }) +}); + +describe('getEndOfWeek()', () => { + it('should correctly return the end of the week', () => { + expect(getEndOfWeek({ + month: 5, + day: 17, + year: 2021, + dayOfWeek: 1 + })).toEqual({ + month: 5, + day: 22, + year: 2021 + }); + + expect(getEndOfWeek({ + month: 5, + day: 31, + year: 2021, + dayOfWeek: 1 + })).toEqual({ + month: 6, + day: 5, + year: 2021, + }); + + expect(getEndOfWeek({ + month: 12, + day: 29, + year: 2021, + dayOfWeek: 3 + })).toEqual({ + month: 1, + day: 1, + year: 2022 + }); + }) +}); + +describe('getNextWeek()', () => { + it('should correctly return the next week', () => { + expect(getNextWeek({ + month: 5, + day: 17, + year: 2021 + })).toEqual({ + month: 5, + day: 24, + year: 2021 + }); + + expect(getNextWeek({ + month: 5, + day: 31, + year: 2021 + })).toEqual({ + month: 6, + day: 7, + year: 2021 + }); + + expect(getNextWeek({ + month: 12, + day: 29, + year: 2021 + })).toEqual({ + month: 1, + day: 5, + year: 2022 + }); + }) +}) + +describe('getPreviousWeek()', () => { + it('should correctly return the previous week', () => { + expect(getPreviousWeek({ + month: 5, + day: 17, + year: 2021 + })).toEqual({ + month: 5, + day: 10, + year: 2021 + }); + + expect(getPreviousWeek({ + month: 5, + day: 1, + year: 2021 + })).toEqual({ + month: 4, + day: 24, + year: 2021 + }); + + expect(getPreviousWeek({ + month: 1, + day: 4, + year: 2021 + })).toEqual({ + month: 12, + day: 28, + year: 2020 + }); + }) +}) + +describe('getNextDay()', () => { + it('should correctly return the next day', () => { + expect(getNextDay({ + month: 5, + day: 17, + year: 2021 + })).toEqual({ + month: 5, + day: 18, + year: 2021 + }); + + expect(getNextDay({ + month: 5, + day: 31, + year: 2021 + })).toEqual({ + month: 6, + day: 1, + year: 2021 + }); + + expect(getNextDay({ + month: 12, + day: 31, + year: 2021 + })).toEqual({ + month: 1, + day: 1, + year: 2022 + }); + }) +}) + +describe('getPreviousDay()', () => { + it('should correctly return the previous day', () => { + expect(getPreviousDay({ + month: 5, + day: 17, + year: 2021 + })).toEqual({ + month: 5, + day: 16, + year: 2021 + }); + + expect(getPreviousDay({ + month: 5, + day: 1, + year: 2021 + })).toEqual({ + month: 4, + day: 30, + year: 2021 + }); + + expect(getPreviousDay({ + month: 1, + day: 1, + year: 2021 + })).toEqual({ + month: 12, + day: 31, + year: 2020 + }); + }) +}) + +describe('getNextMonth()', () => { + it('should return correct next month', () => { + expect(getNextMonth({ month: 5, year: 2021, day: 1 })).toEqual({ + month: 6, + year: 2021, + day: 1 + }); + expect(getNextMonth({ month: 12, year: 2021, day: 30 })).toEqual({ + month: 1, + year: 2022, + day: 30 + }); + expect(getNextMonth({ month: 12, year: 1999, day: 30 })).toEqual({ + month: 1, + year: 2000, + day: 30 + }); + }); +}); + +describe('getPreviousMonth()', () => { + it('should return correct previous month', () => { + expect(getPreviousMonth({ month: 5, year: 2021, day: 1 })).toEqual({ + month: 4, + year: 2021, + day: 1 + }); + expect(getPreviousMonth({ month: 1, year: 2021, day: 30 })).toEqual({ + month: 12, + year: 2020, + day: 30 + }); + expect(getPreviousMonth({ month: 1, year: 2000, day: 30 })).toEqual({ + month: 12, + year: 1999, + day: 30 + }); + }); +}); diff --git a/core/src/components/datetime/test/minmax/e2e.ts b/core/src/components/datetime/test/minmax/e2e.ts new file mode 100644 index 0000000000..d7558359a3 --- /dev/null +++ b/core/src/components/datetime/test/minmax/e2e.ts @@ -0,0 +1,15 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('minmax', async () => { + const page = await newE2EPage({ + url: '/src/components/datetime/test/minmax?ionic:_testing=true' + }); + + const screenshotCompares = []; + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); diff --git a/core/src/components/datetime/test/minmax/index.html b/core/src/components/datetime/test/minmax/index.html new file mode 100644 index 0000000000..a3a9d187b4 --- /dev/null +++ b/core/src/components/datetime/test/minmax/index.html @@ -0,0 +1,65 @@ + + + + + Datetime - Min/Max + + + + + + + + + + + + Datetime - Min/Max + + + +
+
+

Value inside Bounds

+ +
+
+

Value Outside Bounds

+ +
+
+
+
+ + diff --git a/core/src/components/datetime/test/parse.spec.ts b/core/src/components/datetime/test/parse.spec.ts new file mode 100644 index 0000000000..955428b9cd --- /dev/null +++ b/core/src/components/datetime/test/parse.spec.ts @@ -0,0 +1,23 @@ +import { + getPartsFromCalendarDay +} from '../utils/parse'; + +describe('getPartsFromCalendarDay()', () => { + it('should extract DatetimeParts from a calendar day element', () => { + const div = document.createElement('div'); + div.setAttribute('data-month', '4'); + div.setAttribute('data-day', '15'); + div.setAttribute('data-year', '2010'); + div.setAttribute('data-day-of-week', '5'); + + expect(getPartsFromCalendarDay(div)).toEqual({ + day: 15, + month: 4, + year: 2010, + dayOfWeek: 5 + }) + }) +}) + + +// TODO: parseDate() diff --git a/core/src/components/datetime/test/presentation/e2e.ts b/core/src/components/datetime/test/presentation/e2e.ts new file mode 100644 index 0000000000..caf0666fe1 --- /dev/null +++ b/core/src/components/datetime/test/presentation/e2e.ts @@ -0,0 +1,15 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('presentation', async () => { + const page = await newE2EPage({ + url: '/src/components/datetime/test/presentation?ionic:_testing=true' + }); + + const screenshotCompares = []; + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); diff --git a/core/src/components/datetime/test/presentation/index.html b/core/src/components/datetime/test/presentation/index.html new file mode 100644 index 0000000000..3163ef20ed --- /dev/null +++ b/core/src/components/datetime/test/presentation/index.html @@ -0,0 +1,77 @@ + + + + + Datetime - Presentation + + + + + + + + + + + + Datetime - Presentation + + + +
+
+

date-time

+ +
+
+

time-date

+ +
+
+

time

+ +
+
+

date

+ +
+ + + + diff --git a/core/src/components/datetime/test/standalone/e2e.ts b/core/src/components/datetime/test/standalone/e2e.ts deleted file mode 100644 index 93796b58a2..0000000000 --- a/core/src/components/datetime/test/standalone/e2e.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { newE2EPage } from '@stencil/core/testing'; - -test('datetime: standalone', async () => { - const page = await newE2EPage({ - url: '/src/components/datetime/test/standalone?ionic:_testing=true' - }); - - let compare = await page.compareScreenshot(); - expect(compare).toMatchScreenshot(); - - const datetime = await page.find('#basic'); - await datetime.click(); - - const picker = await page.find('ion-picker'); - await picker.waitForVisible(); - await page.waitForTimeout(250); - - compare = await page.compareScreenshot('should open basic picker'); - expect(compare).toMatchScreenshot(); - - const octoberOpt = await page.find({ text: 'October' }); - await octoberOpt.click(); - await page.waitForTimeout(500); - - compare = await page.compareScreenshot('should click "October" option'); - expect(compare).toMatchScreenshot(); -}); diff --git a/core/src/components/datetime/test/standalone/index.html b/core/src/components/datetime/test/standalone/index.html deleted file mode 100644 index 847c6beedf..0000000000 --- a/core/src/components/datetime/test/standalone/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - Datetime - Standalone - - - - - - - - - - - diff --git a/core/src/components/datetime/test/state.spec.ts b/core/src/components/datetime/test/state.spec.ts new file mode 100644 index 0000000000..1bcc0eb5d0 --- /dev/null +++ b/core/src/components/datetime/test/state.spec.ts @@ -0,0 +1,75 @@ +import { + getCalendarDayState, + isDayDisabled +} from '../utils/state'; + +describe('getCalendarDayState()', () => { + it('should return correct state', () => { + const refA = { month: 1, day: 1, year: 2019 }; + const refB = { month: 1, day: 1, year: 2021 }; + const refC = { month: 1, day: 1, year: 2023 }; + + expect(getCalendarDayState('en-US', refA, refB, refC)).toEqual({ + isActive: false, + isToday: false, + disabled: false, + ariaSelected: null, + ariaLabel: 'Tuesday, January 1' + }); + + expect(getCalendarDayState('en-US', refA, refA, refC)).toEqual({ + isActive: true, + isToday: false, + disabled: false, + ariaSelected: 'true', + ariaLabel: 'Tuesday, January 1' + }); + + expect(getCalendarDayState('en-US', refA, refB, refA)).toEqual({ + isActive: false, + isToday: true, + disabled: false, + ariaSelected: null, + ariaLabel: 'Today, Tuesday, January 1' + }); + + expect(getCalendarDayState('en-US', refA, refA, refA)).toEqual({ + isActive: true, + isToday: true, + disabled: false, + ariaSelected: 'true', + ariaLabel: 'Today, Tuesday, January 1' + }); + + expect(getCalendarDayState('en-US', refA, refA, refA, undefined, undefined, [1])).toEqual({ + isActive: true, + isToday: true, + disabled: false, + ariaSelected: 'true', + ariaLabel: 'Today, Tuesday, January 1' + }); + + expect(getCalendarDayState('en-US', refA, refA, refA, undefined, undefined, [2])).toEqual({ + isActive: true, + isToday: true, + disabled: true, + ariaSelected: 'true', + ariaLabel: 'Today, Tuesday, January 1' + }); + }); +}); + +describe('isDayDisabled()', () => { + it('should correctly return whether or not a day is disabled', () => { + const refDate = { month: 5, day: 12, year: 2021 }; + + expect(isDayDisabled(refDate, undefined, undefined)).toEqual(false); + expect(isDayDisabled(refDate, { month: 5, day: 12, year: 2021 }, undefined)).toEqual(false); + expect(isDayDisabled(refDate, { month: 6, day: 12, year: 2021 }, undefined)).toEqual(true); + expect(isDayDisabled(refDate, { month: 5, day: 13, year: 2022 }, undefined)).toEqual(true); + + expect(isDayDisabled(refDate, undefined, { month: 5, day: 12, year: 2021 })).toEqual(false); + expect(isDayDisabled(refDate, undefined, { month: 4, day: 12, year: 2021 })).toEqual(true); + expect(isDayDisabled(refDate, undefined, { month: 5, day: 11, year: 2021 })).toEqual(true); + }) +}); diff --git a/core/src/components/datetime/test/values/e2e.ts b/core/src/components/datetime/test/values/e2e.ts new file mode 100644 index 0000000000..bb6fbb1978 --- /dev/null +++ b/core/src/components/datetime/test/values/e2e.ts @@ -0,0 +1,15 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('values', async () => { + const page = await newE2EPage({ + url: '/src/components/datetime/test/values?ionic:_testing=true' + }); + + const screenshotCompares = []; + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); diff --git a/core/src/components/datetime/test/values/index.html b/core/src/components/datetime/test/values/index.html new file mode 100644 index 0000000000..5e12165803 --- /dev/null +++ b/core/src/components/datetime/test/values/index.html @@ -0,0 +1,69 @@ + + + + + Datetime - Values + + + + + + + + + + + + Datetime - Values + + + +
+
+

Values

+ +
+ +
+

Values with Max/Min

+ +
+
+
+
+ + diff --git a/core/src/components/datetime/usage/angular.md b/core/src/components/datetime/usage/angular.md index 5941e347df..4a6e0369f0 100644 --- a/core/src/components/datetime/usage/angular.md +++ b/core/src/components/datetime/usage/angular.md @@ -1,104 +1,64 @@ ```html - - MMMM - - + + - - MM DD YY - - + + - - Disabled - - + + - - YYYY - - + + - - MMMM YY - - + + - - MM/DD/YYYY - - + + - - MM/DD/YYYY - - + + - - DDD. MMM DD, YY (custom locale) - - + + - - D MMM YYYY H:mm - - + + - - DDDD MMM D, YYYY - - + + +
My Custom Title
+
- - HH:mm - - + + + + Good to go! + Reset + + - - h:mm a - - + +Open Datetime Modal + + + + + - - hh:mm A (15 min steps) - - - - - Leap years, summer months - - - - - Specific days/months/years - - -``` - -```typescript +```javascript @Component({…}) export class MyComponent { - customYearValues = [2020, 2016, 2008, 2004, 2000, 1996]; - customDayShortNames = ['s\u00f8n', 'man', 'tir', 'ons', 'tor', 'fre', 'l\u00f8r']; - customPickerOptions: any; - - constructor() { - this.customPickerOptions = { - buttons: [{ - text: 'Save', - handler: () => console.log('Clicked Save!') - }, { - text: 'Log', - handler: () => { - console.log('Clicked Log. Do not Dismiss.'); - return false; - } - }] - } + @ViewChild('customDatetime', { static: false }) datetime: HTMLIonDateTimeElement; + constructor() {} + + confirm() { + this.datetime.nativeEl.confirm(); + } + + reset() { + this.datetime.nativeEl.reset(); } - } -``` +``` \ No newline at end of file diff --git a/core/src/components/datetime/usage/javascript.md b/core/src/components/datetime/usage/javascript.md index 5de84c38df..83eb726f47 100644 --- a/core/src/components/datetime/usage/javascript.md +++ b/core/src/components/datetime/usage/javascript.md @@ -1,111 +1,60 @@ ```html - - MMMM - - + + - - MM DD YY - - + + - - Disabled - - + + - - YYYY - - + + - - MMMM YY - - + + - - MM/DD/YYYY - - + + - - MM/DD/YYYY - - + + - - DDD. MMM DD, YY (custom locale) - - + + - - D MMM YYYY H:mm - - + + - - DDDD MMM D, YYYY - - + + +
My Custom Title
+
- - HH:mm - - + + + + Good to go! + Reset + + - - h:mm a - - - - - hh:mm A (15 min steps) - - - - - Leap years, summer months - - - - - Specific days/months/years - - -``` + +Open Datetime Modal + + + + + ```javascript -var yearValuesArray = [2020, 2016, 2008, 2004, 2000, 1996]; -var customYearValues = document.getElementById('customYearValues'); -customYearValues.yearValues = yearValuesArray; +const datetime = document.querySelector('#custom-datetime'); -var dayShortNamesArray = [ - 's\u00f8n', - 'man', - 'tir', - 'ons', - 'tor', - 'fre', - 'l\u00f8r' -]; -var customDayShortNames = document.getElementById('customDayShortNames'); -customDayShortNames.dayShortNames = dayShortNamesArray; - -var customPickerButtons = { - buttons: [{ - text: 'Save', - handler: () => console.log('Clicked Save!') - }, { - text: 'Log', - handler: () => { - console.log('Clicked Log. Do not Dismiss.'); - return false; - } - }] +const confirm = () => { + datetime.confirm(); +} + +const reset = () => { + datetime.reset(); } -var customPickerOptions = document.getElementById('customPickerOptions'); -customPickerOptions.pickerOptions = customPickerButtons; ``` \ No newline at end of file diff --git a/core/src/components/datetime/usage/react.md b/core/src/components/datetime/usage/react.md index b8fc967bdd..a8f0e7054a 100644 --- a/core/src/components/datetime/usage/react.md +++ b/core/src/components/datetime/usage/react.md @@ -1,139 +1,79 @@ -```tsx -import React, { useState } from 'react'; -import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar, IonItem, IonLabel, IonDatetime, IonFooter } from '@ionic/react'; - -const customYearValues = [2020, 2016, 2008, 2004, 2000, 1996]; - -const customDayShortNames = [ - 's\u00f8n', - 'man', - 'tir', - 'ons', - 'tor', - 'fre', - 'l\u00f8r' -]; +```javascript +import React, { useState, useRef } from 'react'; +import { + IonButton, + IonButtons, + IonContent, + IonDatetime, + IonModal, + IonPage +} from '@ionic/react'; export const DateTimeExamples: React.FC = () => { const [selectedDate, setSelectedDate] = useState('2012-12-15T13:47:20.789'); + const customDatetime = useRef(); + const confirm = () => { + if (customDatetime === undefined) return; + + customDatetime.confirm(); + } + + const reset = () => { + if (customDatetime === undefined) return; + + customDatetime.reset(); + } + return ( - - - IonDatetime Examples - - - - - MMMM - setSelectedDate(e.detail.value!)}> - - - - MM DD YY - setSelectedDate(e.detail.value!)}> - - - - Disabled - setSelectedDate(e.detail.value!)}> - - - - YYYY - console.log('Clicked Save!') - }, { - text: 'Log', - handler: () => { - console.log('Clicked Log. Do not Dismiss.'); - return false; - } - } - ] - }} - placeholder="Custom Options" displayFormat="YYYY" min="1981" max="2002" - value={selectedDate} onIonChange={e => setSelectedDate(e.detail.value!)}> - - - - - MMMM YY - setSelectedDate(e.detail.value!)}> - - - - MM/DD/YYYY - setSelectedDate(e.detail.value!)}> - - - - MM/DD/YYYY - setSelectedDate(e.detail.value!)}> - - - - DDD. MMM DD, YY (custom locale) - setSelectedDate(e.detail.value!)} - > - - - - D MMM YYYY H:mm - setSelectedDate(e.detail.value!)}> - - - - DDDD MMM D, YYYY - setSelectedDate(e.detail.value!)}> - - - - HH:mm - setSelectedDate(e.detail.value!)}> - - - - h:mm a - setSelectedDate(e.detail.value!)}> - - - - hh:mm A (15 min steps) - setSelectedDate(e.detail.value!)}> - - - - Leap years, summer months - setSelectedDate(e.detail.value!)}> - - - - Specific days/months/years - setSelectedDate(e.detail.value!)} - > - - - - - Selected Date: {selectedDate ?? '(none)'} - - + {/* Initial value */} + setSelectedDate(e.detail.value!)}> + + {/* Readonly */} + + + {/* Disabled */} + + + {/* Custom locale */} + + + {/* Max and min */} + + + {/* 15 minute increments */} + + + {/* Specific days/months/years */} + + + {/* Selecting time, no date */} + + + {/* Selecting time first, date second */} + + + {/* Custom title */} + +
My Custom Title
+
+ + {/* Custom buttons */} + + + confirm()}>Good to go! + reset()}>Reset + + + + {/* Datetime in overlay */} + Open Datetime Modal + + + + +
- ); -}; + ) +} ``` \ No newline at end of file diff --git a/core/src/components/datetime/usage/stencil.md b/core/src/components/datetime/usage/stencil.md index 4be0359da5..998b0623b8 100644 --- a/core/src/components/datetime/usage/stencil.md +++ b/core/src/components/datetime/usage/stencil.md @@ -1,4 +1,4 @@ -```tsx +```javascript import { Component, h } from '@stencil/core'; @Component({ @@ -6,101 +6,72 @@ import { Component, h } from '@stencil/core'; styleUrl: 'datetime-example.css' }) export class DatetimeExample { - private customYearValues = [2020, 2016, 2008, 2004, 2000, 1996]; - private customDayShortNames = ['s\u00f8n', 'man', 'tir', 'ons', 'tor', 'fre', 'l\u00f8r']; - private customPickerOptions = { - buttons: [{ - text: 'Save', - handler: () => console.log('Clicked Save!') - }, { - text: 'Log', - handler: () => { - console.log('Clicked Log. Do not Dismiss.'); - return false; - } - }] + private customDatetime?: HTMLElement; + + private confirm() { + const { customDatetime } = this; + if (customDatetime === undefined) return; + + customDatetime.confirm(); } + private reset() { + const { customDatetime } = this; + if (customDatetime === undefined) return; + + customDatetime.reset(); + } + render() { return [ - - MMMM - - , - - - MM DD YY - - , - - - Disabled - - , - - - YYYY - - , - - - MMMM YY - - , - - - MM/DD/YYYY - - , - - - MM/DD/YYYY - - , - - - DDD. MMM DD, YY (custom locale) - - , - - - D MMM YYYY H:mm - - , - - - DDDD MMM D, YYYY - - , - - - HH:mm - - , - - - h:mm a - - , - - - hh:mm A (15 min steps) - - , - - - Leap years, summer months - - , - - - Specific days/months/years - - - ]; + {/* Initial value */} + , + + {/* Readonly */} + , + + {/* Disabled */} + , + + {/* Custom locale */} + , + + {/* Max and min */} + , + + {/* 15 minute increments */} + , + + {/* Specific days/months/years */} + , + + {/* Selecting time, no date */} + , + + {/* Selecting time first, date second */} + , + + {/* Custom title */} + +
My Custom Title
+
, + + {/* Custom buttons */} + this.customDatetime = el}> + + this.confirm()}>Good to go! + this.reset()}>Reset + + , + + {/* Datetime in overlay */} + Open Datetime Modal + + + + + + ] } } -``` +``` \ No newline at end of file diff --git a/core/src/components/datetime/usage/vue.md b/core/src/components/datetime/usage/vue.md index e66fb6b96f..70b4d32060 100644 --- a/core/src/components/datetime/usage/vue.md +++ b/core/src/components/datetime/usage/vue.md @@ -1,120 +1,90 @@ ```html ``` \ No newline at end of file diff --git a/core/src/components/datetime/utils/comparison.ts b/core/src/components/datetime/utils/comparison.ts new file mode 100644 index 0000000000..e03faf9ff6 --- /dev/null +++ b/core/src/components/datetime/utils/comparison.ts @@ -0,0 +1,34 @@ +import { DatetimeParts } from '../datetime-interface'; + +/** + * Returns true if the selected day is equal to the reference day + */ +export const isSameDay = (baseParts: DatetimeParts, compareParts: DatetimeParts) => { + return ( + baseParts.month === compareParts.month && + baseParts.day === compareParts.day && + baseParts.year === compareParts.year + ); +} + +/** + * Returns true is the selected day is before the reference day. + */ +export const isBefore = (baseParts: DatetimeParts, compareParts: DatetimeParts) => { + return ( + baseParts.year < compareParts.year || + baseParts.year === compareParts.year && baseParts.month < compareParts.month || + baseParts.year === compareParts.year && baseParts.month === compareParts.month && baseParts.day! < compareParts.day! + ); +} + +/** + * Returns true is the selected day is after the reference day. + */ +export const isAfter = (baseParts: DatetimeParts, compareParts: DatetimeParts) => { + return ( + baseParts.year > compareParts.year || + baseParts.year === compareParts.year && baseParts.month > compareParts.month || + baseParts.year === compareParts.year && baseParts.month === compareParts.month && baseParts.day! > compareParts.day! + ); +} diff --git a/core/src/components/datetime/utils/data.ts b/core/src/components/datetime/utils/data.ts new file mode 100644 index 0000000000..25b6716ff0 --- /dev/null +++ b/core/src/components/datetime/utils/data.ts @@ -0,0 +1,290 @@ +import { Mode } from '../../../interface'; +import { DatetimeParts } from '../datetime-interface'; + +import { + isAfter, + isBefore, + isSameDay +} from './comparison'; +import { + getNumDaysInMonth, + is24Hour +} from './helpers'; +import { + getNextMonth, + getPreviousMonth +} from './manipulation'; + +/** + * Returns the current date as + * an ISO string in the user's + * timezone. + */ +export const getToday = () => { + /** + * Grab the current date object + * as well as the timezone offset + */ + const date = new Date(); + const tzOffset = date.getTimezoneOffset(); + + /** + * When converting to ISO string, everything is + * set to UTC. Since we want to show these dates + * relative to the user's timezone, we need to + * subtract the timezone offset from the date + * so that when `toISOString()` adds it back + * there was a net change of zero hours from the + * local date. + */ + date.setHours(date.getHours() - (tzOffset / 60)) + return date.toISOString(); +} + +const minutes = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59]; +const hour12 = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; +const hour24 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]; + +/** + * Given a locale and a mode, + * return an array with formatted days + * of the week. iOS should display days + * such as "Mon" or "Tue". + * MD should display days such as "M" + * or "T". + */ +export const getDaysOfWeek = (locale: string, mode: Mode) => { + /** + * Nov 1st, 2020 starts on a Sunday. + * ion-datetime assumes weeks start + * on Sunday. + */ + const weekdayFormat = mode === 'ios' ? 'short' : 'narrow'; + const intl = new Intl.DateTimeFormat(locale, { weekday: weekdayFormat }) + const startDate = new Date('11/01/2020'); + const daysOfWeek = []; + + /** + * For each day of the week, + * get the day name. + */ + for (let i = 0; i < 7; i++) { + const currentDate = new Date(startDate); + currentDate.setDate(currentDate.getDate() + i); + + daysOfWeek.push(intl.format(currentDate)) + } + + return daysOfWeek; +} + +/** + * Returns an array containing all of the + * days in a month for a given year. Values are + * aligned with a week calendar starting on + * Sunday using null values. + */ +export const getDaysOfMonth = (month: number, year: number) => { + const numDays = getNumDaysInMonth(month, year); + const offset = new Date(`${month}/1/${year}`).getDay() - 1; + + let days = []; + for (let i = 1; i <= numDays; i++) { + days.push({ day: i, dayOfWeek: (offset + i) % 7 }); + } + + for (let i = 0; i <= offset; i++) { + days = [ + { day: null, dayOfWeek: null }, + ...days + ] + } + + return days; +} + +/** + * Given a local, reference datetime parts and option + * max/min bound datetime parts, calculate the acceptable + * hour and minute values according to the bounds and locale. + */ +export const generateTime = ( + locale: string, + refParts: DatetimeParts, + minParts?: DatetimeParts, + maxParts?: DatetimeParts, + hourValues?: number[], + minuteValues?: number[] +) => { + const use24Hour = is24Hour(locale); + let processedHours = use24Hour ? hour24 : hour12; + let processedMinutes = minutes; + let isAMAllowed = true; + let isPMAllowed = true; + + if (hourValues) { + processedHours = processedHours.filter(hour => hourValues.includes(hour)); + } + + if (minuteValues) { + processedMinutes = processedMinutes.filter(minute => minuteValues.includes(minute)) + } + + if (minParts) { + + /** + * If ref day is the same as the + * minimum allowed day, filter hour/minute + * values according to min hour and minute. + */ + if (isSameDay(refParts, minParts)) { + /** + * Users may not always set the hour/minute for + * min value (i.e. 2021-06-02) so we should allow + * all hours/minutes in that case. + */ + if (minParts.hour !== undefined) { + processedHours = processedHours.filter(hour => { + const convertedHour = refParts.ampm === 'pm' ? (hour + 12) % 24 : hour; + return convertedHour >= minParts.hour!; + }); + isAMAllowed = minParts.hour < 13; + } + if (minParts.minute !== undefined) { + processedMinutes = processedMinutes.filter(minute => minute >= minParts.minute!); + } + /** + * If ref day is before minimum + * day do not render any hours/minute values + */ + } else if (isBefore(refParts, minParts)) { + processedHours = []; + processedMinutes = []; + isAMAllowed = isPMAllowed = false; + } + } + + if (maxParts) { + /** + * If ref day is the same as the + * maximum allowed day, filter hour/minute + * values according to max hour and minute. + */ + if (isSameDay(refParts, maxParts)) { + /** + * Users may not always set the hour/minute for + * max value (i.e. 2021-06-02) so we should allow + * all hours/minutes in that case. + */ + if (maxParts.hour !== undefined) { + processedHours = processedHours.filter(hour => { + const convertedHour = refParts.ampm === 'pm' ? (hour + 12) % 24 : hour; + return convertedHour <= maxParts.hour!; + }); + isPMAllowed = maxParts.hour >= 13; + } + if (maxParts.minute !== undefined) { + processedMinutes = processedMinutes.filter(minute => minute <= maxParts.minute!); + } + + /** + * If ref day is after minimum + * day do not render any hours/minute values + */ + } else if (isAfter(refParts, maxParts)) { + processedHours = []; + processedMinutes = []; + isAMAllowed = isPMAllowed = false; + } + } + + return { + hours: processedHours, + minutes: processedMinutes, + am: isAMAllowed, + pm: isPMAllowed, + use24Hour + } +} + +/** + * Given DatetimeParts, generate the previous, + * current, and and next months. + */ +export const generateMonths = (refParts: DatetimeParts): DatetimeParts[] => { + return [ + getPreviousMonth(refParts), + { month: refParts.month, year: refParts.year, day: refParts.day }, + getNextMonth(refParts) + ] +} + +export const getPickerMonths = ( + locale: string, + refParts: DatetimeParts, + minParts?: DatetimeParts, + maxParts?: DatetimeParts, + monthValues?: number[] +) => { + const { year } = refParts; + const months = []; + + if (monthValues !== undefined) { + let processedMonths = monthValues; + if (maxParts?.month !== undefined) { + processedMonths = processedMonths.filter(month => month <= maxParts.month!); + } + if (minParts?.month !== undefined) { + processedMonths = processedMonths.filter(month => month >= minParts.month!); + } + + processedMonths.forEach(processedMonth => { + const date = new Date(`${processedMonth}/1/${year}`); + + const monthString = new Intl.DateTimeFormat(locale, { month: 'long' }).format(date); + months.push({ text: monthString, value: processedMonth }); + }); + } else { + const maxMonth = maxParts && maxParts.year === year ? maxParts.month : 12; + const minMonth = minParts && minParts.year === year ? minParts.month : 1; + + for (let i = minMonth; i <= maxMonth; i++) { + const date = new Date(`${i}/1/${year}`); + + const monthString = new Intl.DateTimeFormat(locale, { month: 'long' }).format(date); + months.push({ text: monthString, value: i }); + } + } + + return months; +} + +export const getCalendarYears = ( + refParts: DatetimeParts, + showOutOfBoundsYears = false, + minParts?: DatetimeParts, + maxParts?: DatetimeParts, + yearValues?: number[] + ) => { + if (yearValues !== undefined) { + let processedYears = yearValues; + if (maxParts?.year !== undefined) { + processedYears = processedYears.filter(year => year <= maxParts.year!); + } + if (minParts?.year !== undefined) { + processedYears = processedYears.filter(year => year >= minParts.year!); + } + return processedYears; + } else { + const { year } = refParts; + const maxYear = (showOutOfBoundsYears) ? year + 20 : (maxParts?.year || year + 20) + const minYear = (showOutOfBoundsYears) ? year - 20 : (minParts?.year || year - 20); + + const years = []; + for (let i = maxYear; i >= minYear; i--) { + years.push(i); + } + + return years; + } +} diff --git a/core/src/components/datetime/utils/format.ts b/core/src/components/datetime/utils/format.ts new file mode 100644 index 0000000000..f7571e65bf --- /dev/null +++ b/core/src/components/datetime/utils/format.ts @@ -0,0 +1,65 @@ +import { DatetimeParts } from '../datetime-interface'; + +/** + * Adds padding to a time value so + * that it is always 2 digits. + */ +export const addTimePadding = (value: number): string => { + const valueToString = value.toString(); + if (valueToString.length > 1) { return valueToString; } + + return `0${valueToString}`; +} + +/** + * Formats the hour value so that it + * is always 2 digits. Only applies + * if using 12 hour format. + */ +export const getFormattedHour = (hour: number, use24Hour: boolean): string => { + if (!use24Hour) { return hour.toString(); } + + return addTimePadding(hour); +} + +/** + * Generates an aria-label to be read by screen readers + * given a local, a date, and whether or not that date is + * today's date. + */ +export const generateDayAriaLabel = (locale: string, today: boolean, refParts: DatetimeParts) => { + if (refParts.day === null) { return null; } + + /** + * MM/DD/YYYY will return midnight in the user's timezone. + */ + const date = new Date(`${refParts.month}/${refParts.day}/${refParts.year}`); + + const labelString = new Intl.DateTimeFormat(locale, { weekday: 'long', month: 'long', day: 'numeric' }).format(date); + + /** + * If date is today, prepend "Today" so screen readers indicate + * that the date is today. + */ + return (today) ? `Today, ${labelString}` : labelString; +} + +/** + * Gets the day of the week, month, and day + * Used for the header in MD mode. + */ +export const getMonthAndDay = (locale: string, refParts: DatetimeParts) => { + const date = new Date(`${refParts.month}/${refParts.day}/${refParts.year}`); + return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric' }).format(date); +} + +/** + * Given a locale and a date object, + * return a formatted string that includes + * the month name and full year. + * Example: May 2021 + */ +export const getMonthAndYear = (locale: string, refParts: DatetimeParts) => { + const date = new Date(`${refParts.month}/${refParts.day}/${refParts.year}`); + return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(date); +} diff --git a/core/src/components/datetime/utils/helpers.ts b/core/src/components/datetime/utils/helpers.ts new file mode 100644 index 0000000000..0381306a00 --- /dev/null +++ b/core/src/components/datetime/utils/helpers.ts @@ -0,0 +1,31 @@ +/** + * Determines if given year is a + * leap year. Returns `true` if year + * is a leap year. Returns `false` + * otherwise. + */ +export const isLeapYear = (year: number) => { + return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); +} + +export const is24Hour = (locale: string) => { + const date = new Date('5/18/2021 00:00'); + const formatted = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).formatToParts(date); + const hour = formatted.find(p => p.type === 'hour'); + + if (!hour) { + throw new Error('Hour value not found from DateTimeFormat'); + } + + return hour.value === '00'; +} + +/** + * Given a date object, returns the number + * of days in that month. + * Month value begin at 1, not 0. + * i.e. January = month 1. + */ +export const getNumDaysInMonth = (month: number, year: number) => { + return (month === 4 || month === 6 || month === 9 || month === 11) ? 30 : (month === 2) ? isLeapYear(year) ? 29 : 28 : 31; +} diff --git a/core/src/components/datetime/utils/manipulation.ts b/core/src/components/datetime/utils/manipulation.ts new file mode 100644 index 0000000000..685aab6866 --- /dev/null +++ b/core/src/components/datetime/utils/manipulation.ts @@ -0,0 +1,305 @@ +import { DatetimeParts } from '../datetime-interface'; + +import { getNumDaysInMonth } from './helpers'; + +const twoDigit = (val: number | undefined): string => { + return ('0' + (val !== undefined ? Math.abs(val) : '0')).slice(-2); +}; + +const fourDigit = (val: number | undefined): string => { + return ('000' + (val !== undefined ? Math.abs(val) : '0')).slice(-4); +}; + +export const convertDataToISO = (data: any): string => { + // https://www.w3.org/TR/NOTE-datetime + let rtn = ''; + if (data.year !== undefined) { + // YYYY + rtn = fourDigit(data.year); + + if (data.month !== undefined) { + // YYYY-MM + rtn += '-' + twoDigit(data.month); + + if (data.day !== undefined) { + // YYYY-MM-DD + rtn += '-' + twoDigit(data.day!); + + if (data.hour !== undefined) { + // YYYY-MM-DDTHH:mm:SS + rtn += `T${twoDigit(data.hour)}:${twoDigit(data.minute)}:00`; + + if (data.tzOffset === undefined) { + // YYYY-MM-DDTHH:mm:SSZ + rtn += 'Z'; + + } else { + + // YYYY-MM-DDTHH:mm:SS+/-HH:mm + rtn += (data.tzOffset > 0 ? '+' : '-') + twoDigit(Math.floor(Math.abs(data.tzOffset / 60))) + ':' + twoDigit(data.tzOffset % 60); + } + } + } + } + + } else if (data.hour !== undefined) { + // HH:mm + rtn = twoDigit(data.hour) + ':' + twoDigit(data.minute); + } + + return rtn; +}; + +/** + * Converts an 12 hour value to 24 hours. + */ +export const convert12HourTo24Hour = (hour: number, ampm?: 'am' | 'pm') => { + if (ampm === undefined) { return hour; } + + /** + * If AM and 12am + * then return 00:00. + * Otherwise just return + * the hour since it is + * already in 24 hour format. + */ + if (ampm === 'am') { + if (hour === 12) { + return 0; + } + + return hour; + } + + /** + * If PM and 12pm + * just return 12:00 + * since it is already + * in 24 hour format. + * Otherwise add 12 hours + * to the time. + */ + if (hour === 12) { + return 12; + } + + return hour + 12; +} + +export const getStartOfWeek = (refParts: DatetimeParts): DatetimeParts => { + const { dayOfWeek } = refParts; + if (dayOfWeek === null || dayOfWeek === undefined) { + throw new Error('No day of week provided'); + } + + return subtractDays(refParts, dayOfWeek); +} + +export const getEndOfWeek = (refParts: DatetimeParts): DatetimeParts => { + const { dayOfWeek } = refParts; + if (dayOfWeek === null || dayOfWeek === undefined) { + throw new Error('No day of week provided'); + } + + return addDays(refParts, 6 - dayOfWeek); +} + +export const getNextDay = (refParts: DatetimeParts): DatetimeParts => { + return addDays(refParts, 1); +} + +export const getPreviousDay = (refParts: DatetimeParts): DatetimeParts => { + return subtractDays(refParts, 1); +} + +export const getPreviousWeek = (refParts: DatetimeParts): DatetimeParts => { + return subtractDays(refParts, 7); +} + +export const getNextWeek = (refParts: DatetimeParts): DatetimeParts => { + return addDays(refParts, 7); +} + +/** + * Given datetime parts, subtract + * numDays from the date. + * Returns a new DatetimeParts object + * Currently can only go backward at most 1 month. + */ +export const subtractDays = (refParts: DatetimeParts, numDays: number) => { + const { month, day, year } = refParts; + if (day === null) { + throw new Error('No day provided'); + } + + const workingParts = { + month, + day, + year + } + + workingParts.day = day - numDays; + + /** + * If wrapping to previous month + * update days and decrement month + */ + if (workingParts.day < 1) { + workingParts.month -= 1; + } + + /** + * If moving to previous year, reset + * month to December and decrement year + */ + if (workingParts.month < 1) { + workingParts.month = 12; + workingParts.year -= 1; + } + + /** + * Determine how many days are in the current + * month + */ + + if (workingParts.day < 1) { + const daysInMonth = getNumDaysInMonth(workingParts.month, workingParts.year); + + /** + * Take num days in month and add the + * number of underflow days. This number will + * be negative. + * Example: 1 week before Jan 2, 2021 is + * December 26, 2021 so: + * 2 - 7 = -5 + * 31 + (-5) = 26 + */ + workingParts.day = daysInMonth + workingParts.day; + } + + return workingParts; +} + +/** + * Given datetime parts, add + * numDays to the date. + * Returns a new DatetimeParts object + * Currently can only go forward at most 1 month. + */ +export const addDays = (refParts: DatetimeParts, numDays: number) => { + const { month, day, year } = refParts; + if (day === null) { + throw new Error('No day provided'); + } + + const workingParts = { + month, + day, + year + } + + const daysInMonth = getNumDaysInMonth(month, year); + workingParts.day = day + numDays; + + /** + * If wrapping to next month + * update days and increment month + */ + if (workingParts.day > daysInMonth) { + workingParts.day -= daysInMonth; + workingParts.month += 1; + } + + /** + * If moving to next year, reset + * month to January and increment year + */ + if (workingParts.month > 12) { + workingParts.month = 1; + workingParts.year += 1; + } + + return workingParts; +} + +/** + * Given DatetimeParts, generate the previous month. + */ +export const getPreviousMonth = (refParts: DatetimeParts) => { + /** + * If current month is January, wrap backwards + * to December of the previous year. + */ + const month = (refParts.month === 1) ? 12 : refParts.month - 1; + const year = (refParts.month === 1) ? refParts.year - 1 : refParts.year; + + const numDaysInMonth = getNumDaysInMonth(month, year); + const day = (numDaysInMonth < refParts.day!) ? numDaysInMonth : refParts.day; + + return { month, year, day }; +} + +/** + * Given DatetimeParts, generate the next month. + */ +export const getNextMonth = (refParts: DatetimeParts) => { + /** + * If current month is December, wrap forwards + * to January of the next year. + */ + const month = (refParts.month === 12) ? 1 : refParts.month + 1; + const year = (refParts.month === 12) ? refParts.year + 1 : refParts.year; + + const numDaysInMonth = getNumDaysInMonth(month, year); + const day = (numDaysInMonth < refParts.day!) ? numDaysInMonth : refParts.day; + + return { month, year, day }; +} + +/** + * If PM, then internal value should + * be converted to 24-hr time. + * Does not apply when public + * values are already 24-hr time. + */ +export const getInternalHourValue = (hour: number, use24Hour: boolean, ampm?: 'am' | 'pm') => { + if (use24Hour) { return hour; } + + return convert12HourTo24Hour(hour, ampm); +} + +/** + * Unless otherwise stated, all month values are + * 1 indexed instead of the typical 0 index in JS Date. + * Example: + * January = Month 0 when using JS Date + * January = Month 1 when using this datetime util + */ + +/** + * Given the current datetime parts and a new AM/PM value + * calculate what the hour should be in 24-hour time format. + * Used when toggling the AM/PM segment since we store our hours + * in 24-hour time format internally. + */ +export const calculateHourFromAMPM = (currentParts: DatetimeParts, newAMPM: 'am' | 'pm') => { + const { ampm: currentAMPM, hour } = currentParts; + + let newHour = hour!; + + /** + * If going from AM --> PM, need to update the + * + */ + if (currentAMPM === 'am' && newAMPM === 'pm') { + newHour = convert12HourTo24Hour(newHour, 'pm'); + + /** + * If going from PM --> AM + */ + } else if (currentAMPM === 'pm' && newAMPM === 'am') { + newHour = Math.abs(newHour - 12); + } + + return newHour; +} diff --git a/core/src/components/datetime/utils/parse.ts b/core/src/components/datetime/utils/parse.ts new file mode 100644 index 0000000000..33c1634520 --- /dev/null +++ b/core/src/components/datetime/utils/parse.ts @@ -0,0 +1,106 @@ +import { DatetimeParts } from '../datetime-interface'; + +const ISO_8601_REGEXP = /^(\d{4}|[+\-]\d{6})(?:-(\d{2})(?:-(\d{2}))?)?(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{3}))?)?(?:(Z)|([+\-])(\d{2})(?::(\d{2}))?)?)?$/; +const TIME_REGEXP = /^((\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{3}))?)?(?:(Z)|([+\-])(\d{2})(?::(\d{2}))?)?)?$/; + +/** + * Use to convert a string of comma separated numbers or + * an array of numbers, and clean up any user input + */ +export const convertToArrayOfNumbers = (input?: number[] | number | string): number[] | undefined => { + if (input === undefined) { return; } + + let processedInput: any = input; + + if (typeof input === 'string') { + // convert the string to an array of strings + // auto remove any whitespace and [] characters + processedInput = input.replace(/\[|\]|\s/g, '').split(','); + } + + let values: number[]; + if (Array.isArray(processedInput)) { + // ensure each value is an actual number in the returned array + values = processedInput + .map((num: any) => parseInt(num, 10)) + .filter(isFinite); + } else { + values = [processedInput as number]; + } + + return values; +}; + +/** + * Extracts date information + * from a .calendar-day element + * into DatetimeParts. + */ +export const getPartsFromCalendarDay = (el: HTMLElement): DatetimeParts => { + return { + month: parseInt(el.getAttribute('data-month')!, 10), + day: parseInt(el.getAttribute('data-day')!, 10), + year: parseInt(el.getAttribute('data-year')!, 10), + dayOfWeek: parseInt(el.getAttribute('data-day-of-week')!, 10) + } +} + +/** + * Given an ISO-8601 string, format out the parts + * We do not use the JS Date object here because + * it adjusts the date for the current timezone. + */ +export const parseDate = (val: string | undefined | null): any | undefined => { + // manually parse IS0 cuz Date.parse cannot be trusted + // ISO 8601 format: 1994-12-15T13:47:20Z + let parse: any[] | null = null; + + if (val != null && val !== '') { + // try parsing for just time first, HH:MM + parse = TIME_REGEXP.exec(val); + if (parse) { + // adjust the array so it fits nicely with the datetime parse + parse.unshift(undefined, undefined); + parse[2] = parse[3] = undefined; + + } else { + // try parsing for full ISO datetime + parse = ISO_8601_REGEXP.exec(val); + } + } + + if (parse === null) { + // wasn't able to parse the ISO datetime + return undefined; + } + + // ensure all the parse values exist with at least 0 + for (let i = 1; i < 8; i++) { + parse[i] = parse[i] !== undefined ? parseInt(parse[i], 10) : undefined; + } + + let tzOffset = 0; + if (parse[9] && parse[10]) { + // hours + tzOffset = parseInt(parse[10], 10) * 60; + if (parse[11]) { + // minutes + tzOffset += parseInt(parse[11], 10); + } + if (parse[9] === '-') { + // + or - + tzOffset *= -1; + } + } + + return { + year: parse[1], + month: parse[2], + day: parse[3], + hour: parse[4], + minute: parse[5], + second: parse[6], + millisecond: parse[7], + tzOffset, + }; +}; diff --git a/core/src/components/datetime/utils/state.ts b/core/src/components/datetime/utils/state.ts new file mode 100644 index 0000000000..e91acd40e9 --- /dev/null +++ b/core/src/components/datetime/utils/state.ts @@ -0,0 +1,117 @@ +import { DatetimeParts } from '../datetime-interface'; + +import { isAfter, isBefore, isSameDay } from './comparison'; +import { generateDayAriaLabel } from './format'; + +export const isYearDisabled = (refYear: number, minParts?: DatetimeParts, maxParts?: DatetimeParts) => { + if (minParts && minParts.year > refYear) { + return true; + } + + if (maxParts && maxParts.year < refYear) { + return true; + } + + return false; +} + +/** + * Returns true if a given day should + * not be interactive according to its value, + * or the max/min dates. + */ +export const isDayDisabled = ( + refParts: DatetimeParts, + minParts?: DatetimeParts, + maxParts?: DatetimeParts, + dayValues?: number[] +) => { + /** + * If this is a filler date (i.e. padding) + * then the date is disabled. + */ + if (refParts.day === null) { return true; } + + /** + * If user passed in a list of acceptable day values + * check to make sure that the date we are looking + * at is in this array. + */ + if (dayValues !== undefined && !dayValues.includes(refParts.day)) { return true; } + + /** + * Given a min date, perform the following + * checks. If any of them are true, then the + * day should be disabled: + * 1. Is the current year < the min allowed year? + * 2. Is the current year === min allowed year, + * but the current month < the min allowed month? + * 3. Is the current year === min allowed year, the + * current month === min allow month, but the current + * day < the min allowed day? + */ + if (minParts && isBefore(refParts, minParts)) { + return true; + } + + /** + * Given a max date, perform the following + * checks. If any of them are true, then the + * day should be disabled: + * 1. Is the current year > the max allowed year? + * 2. Is the current year === max allowed year, + * but the current month > the max allowed month? + * 3. Is the current year === max allowed year, the + * current month === max allow month, but the current + * day > the max allowed day? + */ + if (maxParts && isAfter(refParts, maxParts)) { + return true; + } + + /** + * If none of these checks + * passed then the date should + * be interactive. + */ + return false; +} + +export const getCalendarYearState = (refYear: number, activeParts: DatetimeParts, todayParts: DatetimeParts, minParts?: DatetimeParts, maxParts?: DatetimeParts) => { + const isActiveYear = refYear === activeParts.year; + const isCurrentYear = refYear === todayParts.year; + const disabled = isYearDisabled(refYear, minParts, maxParts); + + return { + disabled, + isActiveYear, + isCurrentYear, + ariaSelected: isActiveYear ? 'true' : null + } +} + +/** + * Given a locale, a date, the selected date, and today's date, + * generate the state for a given calendar day button. + */ +export const getCalendarDayState = ( + locale: string, + refParts: DatetimeParts, + activeParts: DatetimeParts, + todayParts: DatetimeParts, + minParts?: DatetimeParts, + maxParts?: DatetimeParts, + dayValues?: number[] +) => { + const isActive = isSameDay(refParts, activeParts); + const isToday = isSameDay(refParts, todayParts); + const disabled = isDayDisabled(refParts, minParts, maxParts, dayValues); + + return { + disabled, + isActive, + isToday, + ariaSelected: isActive ? 'true' : null, + ariaLabel: generateDayAriaLabel(locale, isToday, refParts) + } +} diff --git a/core/src/components/item/readme.md b/core/src/components/item/readme.md index 3d626c448f..f1326058a2 100644 --- a/core/src/components/item/readme.md +++ b/core/src/components/item/readme.md @@ -1922,6 +1922,7 @@ export default defineComponent({ ### Used by + - [ion-datetime](../datetime) - ion-select-popover ### Depends on @@ -1934,6 +1935,7 @@ export default defineComponent({ graph TD; ion-item --> ion-icon ion-item --> ion-ripple-effect + ion-datetime --> ion-item ion-select-popover --> ion-item style ion-item fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/core/src/components/label/readme.md b/core/src/components/label/readme.md index 8ed38b0ea7..78a4c49198 100644 --- a/core/src/components/label/readme.md +++ b/core/src/components/label/readme.md @@ -303,11 +303,13 @@ export default defineComponent({ ### Used by + - [ion-datetime](../datetime) - ion-select-popover ### Graph ```mermaid graph TD; + ion-datetime --> ion-label ion-select-popover --> ion-label style ion-label fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/core/src/components/modal/modal.ios.scss b/core/src/components/modal/modal.ios.scss index 85f5a46a41..0bdf9db715 100644 --- a/core/src/components/modal/modal.ios.scss +++ b/core/src/components/modal/modal.ios.scss @@ -68,3 +68,10 @@ box-shadow: var(--box-shadow); } } + +:host(.overlay-datetime) { + --width: 316px; + --height: 296px; + --background: transparent; + --border-radius: #{$modal-ios-border-radius}; +} diff --git a/core/src/components/segment-button/readme.md b/core/src/components/segment-button/readme.md index 921a9d8b2f..11c2bb7584 100644 --- a/core/src/components/segment-button/readme.md +++ b/core/src/components/segment-button/readme.md @@ -852,6 +852,10 @@ export default defineComponent({ ## Dependencies +### Used by + + - [ion-datetime](../datetime) + ### Depends on - [ion-ripple-effect](../ripple-effect) @@ -860,6 +864,7 @@ export default defineComponent({ ```mermaid graph TD; ion-segment-button --> ion-ripple-effect + ion-datetime --> ion-segment-button style ion-segment-button fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/core/src/components/segment/readme.md b/core/src/components/segment/readme.md index c0db0f3051..dd4ab5ac8a 100644 --- a/core/src/components/segment/readme.md +++ b/core/src/components/segment/readme.md @@ -590,6 +590,19 @@ export default defineComponent({ | `--background` | Background of the segment button | +## Dependencies + +### Used by + + - [ion-datetime](../datetime) + +### Graph +```mermaid +graph TD; + ion-datetime --> ion-segment + style ion-segment fill:#f9f,stroke:#333,stroke-width:4px +``` + ---------------------------------------------- *Built with [StencilJS](https://stenciljs.com/)* diff --git a/core/src/components/segment/segment.tsx b/core/src/components/segment/segment.tsx index 3fb0098220..0a8c032708 100644 --- a/core/src/components/segment/segment.tsx +++ b/core/src/components/segment/segment.tsx @@ -331,7 +331,17 @@ export class Segment implements ComponentInterface { const currentX = detail.currentX; const previousY = rect.top + (rect.height / 2); - const nextEl = document.elementFromPoint(currentX, previousY) as HTMLIonSegmentButtonElement; + + /** + * Segment can be used inside the shadow dom + * so doing document.elementFromPoint would never + * return a segment button in that instance. + * We use getRootNode to which will return the parent + * shadow root if used inside a shadow component and + * returns document otherwise. + */ + const root = this.el.getRootNode() as Document | ShadowRoot; + const nextEl = root.elementFromPoint(currentX, previousY) as HTMLIonSegmentButtonElement; const decreaseIndex = isRTL ? currentX > (left + width) : currentX < left; const increaseIndex = isRTL ? currentX < left : currentX > (left + width); diff --git a/core/src/utils/focus-visible.ts b/core/src/utils/focus-visible.ts index 529cd67b19..6077d61bc7 100644 --- a/core/src/utils/focus-visible.ts +++ b/core/src/utils/focus-visible.ts @@ -3,12 +3,14 @@ const ION_FOCUSED = 'ion-focused'; const ION_FOCUSABLE = 'ion-focusable'; const FOCUS_KEYS = ['Tab', 'ArrowDown', 'Space', 'Escape', ' ', 'Shift', 'Enter', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'Home', 'End']; -export const startFocusVisible = () => { +export const startFocusVisible = (rootEl?: HTMLElement) => { let currentFocus: Element[] = []; let keyboardMode = true; - const doc = document; + const ref = (rootEl) ? rootEl.shadowRoot! : document; + const root = (rootEl) ? rootEl : document.body; + const setFocus = (elements: Element[]) => { currentFocus.forEach(el => el.classList.remove(ION_FOCUSED)); elements.forEach(el => el.classList.add(ION_FOCUSED)); @@ -19,14 +21,13 @@ export const startFocusVisible = () => { setFocus([]); }; - doc.addEventListener('keydown', ev => { + const onKeydown = (ev: any) => { keyboardMode = FOCUS_KEYS.includes(ev.key); if (!keyboardMode) { setFocus([]); } - }); - - doc.addEventListener('focusin', ev => { + } + const onFocusin = (ev: Event) => { if (keyboardMode && ev.composedPath) { const toFocus = ev.composedPath().filter((el: any) => { if (el.classList) { @@ -36,12 +37,24 @@ export const startFocusVisible = () => { }) as Element[]; setFocus(toFocus); } - }); - doc.addEventListener('focusout', () => { - if (doc.activeElement === doc.body) { + } + const onFocusout = () => { + if (ref.activeElement === root) { setFocus([]); } - }); - doc.addEventListener('touchstart', pointerDown); - doc.addEventListener('mousedown', pointerDown); + } + + ref.addEventListener('keydown', onKeydown); + ref.addEventListener('focusin', onFocusin); + ref.addEventListener('focusout', onFocusout); + ref.addEventListener('touchstart', pointerDown); + ref.addEventListener('mousedown', pointerDown); + + return () => { + ref.removeEventListener('keydown', onKeydown); + ref.removeEventListener('focusin', onFocusin); + ref.removeEventListener('focusout', onFocusout); + ref.removeEventListener('touchstart', pointerDown); + ref.removeEventListener('mousedown', pointerDown); + } }; diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 427568d984..b6f58bcf0f 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -173,14 +173,13 @@ export const IonContent = /*@__PURE__*/ defineContainer('ion-con export const IonDatetime = /*@__PURE__*/ defineContainer('ion-datetime', [ + 'color', 'name', 'disabled', 'readonly', 'min', 'max', - 'displayFormat', - 'displayTimezone', - 'pickerFormat', + 'presentation', 'cancelText', 'doneText', 'yearValues', @@ -188,13 +187,10 @@ export const IonDatetime = /*@__PURE__*/ defineContainer('ion-d 'dayValues', 'hourValues', 'minuteValues', - 'monthNames', - 'monthShortNames', - 'dayNames', - 'dayShortNames', - 'pickerOptions', - 'placeholder', + 'locale', 'value', + 'showDefaultTitle', + 'showDefaultButtons', 'ionCancel', 'ionChange', 'ionFocus', diff --git a/packages/vue/test-app/cypress.json b/packages/vue/test-app/cypress.json index 5c73b90b28..c45e01b013 100644 --- a/packages/vue/test-app/cypress.json +++ b/packages/vue/test-app/cypress.json @@ -1,6 +1,5 @@ { "pluginsFile": "tests/e2e/plugins/index.js", - "includeShadowDom": true, "video": false, "screenshotOnRunFailure": false, "defaultCommandTimeout": 10000 diff --git a/packages/vue/test-app/tests/e2e/specs/overlays.js b/packages/vue/test-app/tests/e2e/specs/overlays.js index 5ab0ceadca..5026fd244a 100644 --- a/packages/vue/test-app/tests/e2e/specs/overlays.js +++ b/packages/vue/test-app/tests/e2e/specs/overlays.js @@ -70,7 +70,7 @@ describe('Overlays', () => { cy.get('ion-button#present-overlay').click(); cy.get('ion-toast').should('exist'); - cy.get('ion-toast').find('button').click(); + cy.get('ion-toast').shadow().find('button').click(); cy.get('ion-toast').should('not.exist'); }); @@ -102,7 +102,7 @@ describe('Overlays', () => { cy.get('ion-button#present-overlay').click(); cy.get('ion-toast').should('exist'); - cy.get('ion-toast').find('button').click(); + cy.get('ion-toast').shadow().find('button').click(); cy.get('ion-toast').should('not.exist'); });