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 { Color, DatetimeChangeEventDetail, DatetimeParts, Mode, StyleEventDetail } from '../../interface'; import { startFocusVisible } from '../../utils/focus-visible'; import { getElementRoot, raf, renderHiddenInput } from '../../utils/helpers'; import { createColorClasses } from '../../utils/theme'; import { PickerColumnItem } from '../picker-column-internal/picker-column-internal-interfaces'; import { generateMonths, generateTime, getCalendarYears, getDaysOfMonth, getDaysOfWeek, getPickerMonths, getToday } from './utils/data'; import { addTimePadding, getFormattedHour, getFormattedTime, getMonthAndDay, getMonthAndYear } from './utils/format'; import { is24Hour } from './utils/helpers'; import { calculateHourFromAMPM, convertDataToISO, getEndOfWeek, getInternalHourValue, getNextDay, getNextMonth, getNextWeek, getNextYear, getPreviousDay, getPreviousMonth, getPreviousWeek, getPreviousYear, getStartOfWeek } from './utils/manipulation'; import { convertToArrayOfNumbers, getPartsFromCalendarDay, parseDate } from './utils/parse'; import { getCalendarDayState, isDayDisabled } from './utils/state'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * * @slot title - The title of the datetime. * @slot buttons - The buttons in the datetime. * @slot time-label - The label for the time selector in the datetime. */ @Component({ tag: 'ion-datetime', styleUrls: { ios: 'datetime.ios.scss', md: 'datetime.md.scss' }, shadow: true }) export class Datetime implements ComponentInterface { private inputId = `ion-dt-${datetimeIds++}`; private calendarBodyRef?: HTMLElement; private popoverRef?: HTMLIonPopoverElement; private clearFocusVisible?: () => void; private overlayIsPresenting = false; private parsedMinuteValues?: number[]; private parsedHourValues?: number[]; private parsedMonthValues?: number[]; private parsedYearValues?: number[]; private parsedDayValues?: number[]; private destroyCalendarIO?: () => void; private destroyKeyboardMO?: () => void; private minParts?: any; private maxParts?: any; /** * Duplicate reference to `activeParts` that does not trigger a re-render of the component. * Allows caching an instance of the `activeParts` in between render cycles. */ private activePartsClone!: DatetimeParts; @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() isPresented = false; @State() isTimePopoverOpen = 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. */ @Prop() name: string = this.inputId; /** * If `true`, the user cannot interact with the datetime. */ @Prop() disabled = false; /** * If `true`, the datetime appears normal but is not interactive. */ @Prop() readonly = false; @Watch('disabled') protected disabledChanged() { this.emitStyle(); } /** * 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. */ @Prop({ mutable: true }) min?: string; @Watch('min') protected minChanged() { this.processMinParts(); } /** * 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. */ @Prop({ mutable: true }) max?: string; @Watch('max') protected maxChanged() { this.processMaxParts(); } /** * 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() presentation: 'date-time' | 'time-date' | 'date' | 'time' | 'month' | 'year' | 'month-year' = 'date-time'; /** * The text to display on the picker's cancel button. */ @Prop() cancelText = 'Cancel'; /** * The text to display on the picker's "Done" button. */ @Prop() doneText = 'Done'; /** * The text to display on the picker's "Clear" button. */ @Prop() clearText = 'Clear'; /** * 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"`. */ @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 * 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`. */ @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 * 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. */ @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 * 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. */ @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 * 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"`. */ @Prop() minuteValues?: number[] | number | string; @Watch('minuteValues') protected minuteValuesChanged() { this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues); } /** * 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() locale = 'default'; /** * The first day of the week to use for `ion-datetime`. The * default value is `0` and represents Sunday. */ @Prop() firstDayOfWeek = 0; /** * The value of the datetime as a valid ISO 8601 datetime string. */ @Prop({ mutable: true }) value?: string | null; /** * Update the datetime value when the value changes */ @Watch('value') protected valueChanged() { if (this.hasValue()) { /** * Clones the value of the `activeParts` to the private clone, to update * the date display on the current render cycle without causing another render. * * This allows us to update the current value's date/time display without * refocusing or shifting the user's display (leaves the user in place). */ const { month, day, year, hour, minute } = parseDate(this.value); this.activePartsClone = { ...this.activeParts, month, day, year, hour, minute } } 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; /** * If `true`, a "Clear" button will be rendered alongside * the default "Cancel" and "OK" buttons 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() showClearButton = false; /** * If `true`, the default "Time" label will be rendered * for the time selector of the `ion-datetime` component. * Developers can also use the `time-label` slot * if they want to customize this label. If a custom * label is set in the `time-label` slot then the * default label will not be rendered. */ @Prop() showDefaultTimeLabel = true; /** * The hour cycle of the `ion-datetime`. If no value is set, this is * specified by the current locale. */ @Prop() hourCycle?: 'h23' | 'h12'; /** * If `cover`, the `ion-datetime` will expand to cover the full width of its container. * If `fixed`, the `ion-datetime` will have a fixed width. */ @Prop() size: 'cover' | 'fixed' = 'fixed'; /** * Emitted when the datetime selection was cancelled. */ @Event() ionCancel!: EventEmitter; /** * Emitted when the value (selected date) has changed. */ @Event() ionChange!: EventEmitter; /** * Emitted when the datetime has focus. */ @Event() ionFocus!: EventEmitter; /** * Emitted when the datetime loses focus. */ @Event() ionBlur!: EventEmitter; /** * Emitted when the styles change. * @internal */ @Event() ionStyle!: EventEmitter; /** * 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.value = convertDataToISO(this.workingParts); if (closeOverlay) { this.closeParentOverlay(); } } /** * Resets the internal state of the datetime but does not update the value. * Passing a valid ISO-8601 string will reset the state of the component to the provided date. * If no value is provided, the internal state will be reset to today. */ @Method() async reset(startDate?: string) { this.processValue(startDate); } /** * 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 }); this.destroyKeyboardMO = () => { mo?.disconnect(); } /** * 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 = ev.shiftKey ? getPreviousYear(parts) : getPreviousMonth(parts); break; case 'PageDown': ev.preventDefault(); partsToFocus = ev.shiftKey ? getNextYear(parts) : 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 { month, day, year, hour, minute } = parseDate(this.min); this.minParts = { month, day, year, hour, minute } } private processMaxParts = () => { if (this.max === undefined) { this.maxParts = undefined; return; } const { month, day, year, hour, minute } = parseDate(this.max); 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 }); calendarBodyRef.scrollLeft = workingMonth.clientWidth; 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); }); } /** * 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); this.destroyCalendarIO = () => { endIO?.disconnect(); startIO?.disconnect(); } }); } connectedCallback() { this.clearFocusVisible = startFocusVisible(this.el).destroy; } disconnectedCallback() { if (this.clearFocusVisible) { this.clearFocusVisible(); this.clearFocusVisible = undefined; } } /** * Clean up all listeners except for the overlay * listener. This is so that we can re-create the listeners * if the datetime has been hidden/presented by a modal or popover. */ private destroyListeners = () => { const { destroyCalendarIO, destroyKeyboardMO } = this; if (destroyCalendarIO !== undefined) { destroyCalendarIO(); } if (destroyKeyboardMO !== undefined) { destroyKeyboardMO(); } } componentDidLoad() { /** * 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.initializeCalendarIOListeners(); this.initializeKeyboardListeners(); this.initializeOverlayListener(); /** * 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 }); /** * Use raf to avoid a race condition between the component loading and * its display animation starting (such as when shown in a modal). This * could cause the datetime to start at a visibility of 0, erroneously * triggering the `hiddenIO` observer below. */ raf(() => visibleIO?.observe(this.el)); /** * We need to clean up listeners when the datetime is hidden * in a popover/modal so that we can properly scroll containers * back into view if they are re-presented. When the datetime is hidden * the scroll areas have scroll widths/heights of 0px, so any snapping * we did originally has been lost. */ let hiddenIO: IntersectionObserver | undefined; const hiddenCallback = (entries: IntersectionObserverEntry[]) => { const ev = entries[0]; if (ev.isIntersecting) { return; } this.destroyListeners(); writeTask(() => { this.el.classList.remove('datetime-ready'); }); } hiddenIO = new IntersectionObserver(hiddenCallback, { threshold: 0 }); raf(() => hiddenIO?.observe(this.el)); /** * Datetime uses Ionic components that emit * ionFocus and ionBlur. These events are * composed meaning they will cross * the shadow dom boundary. We need to * stop propagation on these events otherwise * developers will see 2 ionFocus or 2 ionBlur * events at a time. */ const root = getElementRoot(this.el); root.addEventListener('ionFocus', (ev: Event) => ev.stopPropagation()); root.addEventListener('ionBlur', (ev: Event) => ev.stopPropagation()); } /** * 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; }); } 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.activePartsClone = 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, 'interactive-disabled': this.disabled, }); } private onFocus = () => { this.ionFocus.emit(); } private onBlur = () => { this.ionBlur.emit(); } private hasValue = () => { return this.value != null && this.value !== ''; } private nextMonth = () => { const { calendarBodyRef } = this; if (!calendarBodyRef) { return; } const nextMonth = calendarBodyRef.querySelector('.calendar-month:last-of-type'); if (!nextMonth) { return; } calendarBodyRef.scrollTo({ top: 0, left: (nextMonth as HTMLElement).offsetWidth * 2, behavior: 'smooth' }); } private prevMonth = () => { const { calendarBodyRef } = this; if (!calendarBodyRef) { return; } const prevMonth = calendarBodyRef.querySelector('.calendar-month:first-of-type'); if (!prevMonth) { return; } calendarBodyRef.scrollTo({ top: 0, left: 0, behavior: 'smooth' }); } private renderFooter() { const { showDefaultButtons, showClearButton } = this; const hasSlottedButtons = this.el.querySelector('[slot="buttons"]') !== null; if (!hasSlottedButtons && !showDefaultButtons && !showClearButton) { return; } const clearButtonClick = () => { this.reset(); this.value = undefined; } /** * 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 ( ); } private toggleMonthAndYearView = () => { this.showMonthAndYear = !this.showMonthAndYear; } private renderYearView() { const { presentation, workingParts } = this; const calendarYears = getCalendarYears(this.todayParts, this.minParts, this.maxParts, this.parsedYearValues); const showMonth = presentation !== 'year'; const showYear = presentation !== 'month'; const months = getPickerMonths(this.locale, workingParts, this.minParts, this.maxParts, this.parsedMonthValues); const years = calendarYears.map(year => { return { text: `${year}`, value: year } }) return (
{ showMonth && { this.setWorkingParts({ ...this.workingParts, month: ev.detail.value }); if (presentation === 'month' || presentation === 'month-year') { this.setActiveParts({ ...this.activeParts, month: ev.detail.value }); } ev.stopPropagation(); }} > } { showYear && { this.setWorkingParts({ ...this.workingParts, year: ev.detail.value }); if (presentation === 'year' || presentation === 'month-year') { this.setActiveParts({ ...this.activeParts, year: ev.detail.value }); } ev.stopPropagation(); }} > }
); } 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, this.firstDayOfWeek % 7).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, this.firstDayOfWeek % 7).map((dateObject, index) => { const { day, dayOfWeek } = dateObject; const referenceParts = { month, day, year }; const { isActive, isToday, ariaLabel, ariaSelected, disabled } = getCalendarDayState(this.locale, referenceParts, this.activePartsClone, 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()}
) } private renderTimeLabel() { const hasSlottedTimeLabel = this.el.querySelector('[slot="time-label"]') !== null; if (!hasSlottedTimeLabel && !this.showDefaultTimeLabel) { return; } return ( Time ); } private renderTimePicker( hoursItems: PickerColumnItem[], minutesItems: PickerColumnItem[], ampmItems: PickerColumnItem[], use24Hour: boolean ) { const { color, activePartsClone, workingParts } = this; return ( { this.setWorkingParts({ ...workingParts, hour: ev.detail.value }); this.setActiveParts({ ...activePartsClone, hour: ev.detail.value }); ev.stopPropagation(); }} > { this.setWorkingParts({ ...workingParts, minute: ev.detail.value }); this.setActiveParts({ ...activePartsClone, minute: ev.detail.value }); ev.stopPropagation(); }} > { !use24Hour && { const hour = calculateHourFromAMPM(workingParts, ev.detail.value); this.setWorkingParts({ ...workingParts, ampm: ev.detail.value, hour }); this.setActiveParts({ ...activePartsClone, ampm: ev.detail.value, hour }); ev.stopPropagation(); }} > } ) } private renderTimeOverlay( hoursItems: PickerColumnItem[], minutesItems: PickerColumnItem[], ampmItems: PickerColumnItem[], use24Hour: boolean ) { return [
{this.renderTimeLabel()}
, , this.popoverRef = el} > {this.renderTimePicker(hoursItems, minutesItems, ampmItems, use24Hour)} ] } /** * 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() { const { workingParts, presentation } = this; const timeOnlyPresentation = presentation === 'time'; const use24Hour = is24Hour(this.locale, this.hourCycle); const { hours, minutes, am, pm } = generateTime(this.workingParts, use24Hour ? 'h23' : 'h12', this.minParts, this.maxParts, this.parsedHourValues, this.parsedMinuteValues); const hoursItems = hours.map(hour => { return { text: getFormattedHour(hour, use24Hour), value: getInternalHourValue(hour, use24Hour, workingParts.ampm) } }); const minutesItems = minutes.map(minute => { return { text: addTimePadding(minute), value: minute } }); const ampmItems = []; if (am) { ampmItems.push({ text: 'AM', value: 'am' }) } if (pm) { ampmItems.push({ text: 'PM', value: 'pm' }) } return (
{timeOnlyPresentation ? this.renderTimePicker(hoursItems, minutesItems, ampmItems, use24Hour) : this.renderTimeOverlay(hoursItems, minutesItems, ampmItems, use24Hour)}
) } 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)}
}
); } private renderDatetime(mode: Mode) { const { presentation } = this; switch (presentation) { case 'date-time': return [ this.renderCalendarViewHeader(mode), this.renderCalendar(mode), this.renderYearView(), this.renderTime(), this.renderFooter() ] case 'time-date': return [ this.renderCalendarViewHeader(mode), this.renderTime(), this.renderCalendar(mode), this.renderYearView(), this.renderFooter() ] case 'time': return [ this.renderTime(), this.renderFooter() ] case 'month': case 'month-year': case 'year': return [ this.renderYearView(), this.renderFooter() ] default: return [ this.renderCalendarViewHeader(mode), this.renderCalendar(mode), this.renderYearView(), this.renderFooter() ] } } render() { const { name, value, disabled, el, color, isPresented, readonly, showMonthAndYear, presentation, size } = this; const mode = getIonMode(this); const isMonthAndYearPresentation = presentation === 'year' || presentation === 'month' || presentation === 'month-year'; const shouldShowMonthAndYear = showMonthAndYear || isMonthAndYearPresentation; renderHiddenInput(true, el, name, value, disabled); return ( {this.renderDatetime(mode)} ); } } let datetimeIds = 0;