import type { ComponentInterface } from '@stencil/core'; import { Component, Element, Host, Prop, State, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; import type { Color, DatetimePresentation } from '../../interface'; import { componentOnReady, addEventListener } from '../../utils/helpers'; import { printIonError } from '../../utils/logging'; import { createColorClasses } from '../../utils/theme'; import { getToday } from '../datetime/utils/data'; import { getMonthAndYear, getMonthDayAndYear, getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format'; import { is24Hour } from '../datetime/utils/helpers'; import { parseDate } from '../datetime/utils/parse'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * * @slot date-target - Content displayed inside of the date button. * @slot time-target - Content displayed inside of the time button. * * @part native - The native HTML button that wraps the slotted text. */ @Component({ tag: 'ion-datetime-button', styleUrls: { ios: 'datetime-button.scss', md: 'datetime-button.scss', }, shadow: true, }) export class DatetimeButton implements ComponentInterface { private datetimeEl: HTMLIonDatetimeElement | null = null; private overlayEl: HTMLIonModalElement | HTMLIonPopoverElement | null = null; private dateTargetEl: HTMLElement | undefined; private timeTargetEl: HTMLElement | undefined; @Element() el!: HTMLIonDatetimeButtonElement; @State() datetimePresentation?: DatetimePresentation = 'date-time'; @State() dateText?: string; @State() timeText?: string; @State() datetimeActive = false; @State() selectedButton?: 'date' | 'time'; /** * 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({ reflect: true }) color?: Color = 'primary'; /** * If `true`, the user cannot interact with the button. */ @Prop({ reflect: true }) disabled = false; /** * The ID of the `ion-datetime` instance * associated with the datetime button. */ @Prop() datetime?: string; async componentWillLoad() { const { datetime } = this; if (!datetime) { printIonError( 'An ID associated with an ion-datetime instance is required for ion-datetime-button to function properly.', this.el ); return; } const datetimeEl = (this.datetimeEl = document.getElementById(datetime) as HTMLIonDatetimeElement | null); if (!datetimeEl) { printIonError(`No ion-datetime instance found for ID '${datetime}'.`, this.el); return; } /** * Since the datetime can be used in any context (overlays, accordion, etc) * we track when it is visible to determine when it is active. * This informs which button is highlighted as well as the * aria-expanded state. */ const io = new IntersectionObserver( (entries: IntersectionObserverEntry[]) => { const ev = entries[0]; this.datetimeActive = ev.isIntersecting; }, { threshold: 0.01, } ); io.observe(datetimeEl); /** * Get a reference to any modal/popover * the datetime is being used in so we can * correctly size it when it is presented. */ const overlayEl = (this.overlayEl = datetimeEl.closest('ion-modal, ion-popover')); /** * The .ion-datetime-button-overlay class contains * styles that allow any modal/popover to be * sized according to the dimensions of the datetime. * If developers want a smaller/larger overlay all they need * to do is change the width/height of the datetime. * Additionally, this lets us avoid having to set * explicit widths on each variant of datetime. */ if (overlayEl) { overlayEl.classList.add('ion-datetime-button-overlay'); } componentOnReady(datetimeEl, () => { const datetimePresentation = (this.datetimePresentation = datetimeEl.presentation || 'date-time'); /** * Set the initial display * in the rendered buttons. * * From there, we need to listen * for ionChange to be emitted * from datetime so we know when * to re-render the displayed * text in the buttons. */ this.setDateTimeText(); addEventListener(datetimeEl, 'ionChange', this.setDateTimeText); /** * Configure the initial selected button * in the event that the datetime is displayed * without clicking one of the datetime buttons. * For example, a datetime could be expanded * in an accordion. In this case users only * need to click the accordion header to show * the datetime. */ switch (datetimePresentation) { case 'date-time': case 'date': case 'month-year': case 'month': case 'year': this.selectedButton = 'date'; break; case 'time-date': case 'time': this.selectedButton = 'time'; break; } }); } /** * Accepts one or more string values and converts * them to DatetimeParts. This is done so datetime-button * can work with an array internally and not need * to keep checking if the datetime value is `string` or `string[]`. */ private getParsedDateValues = (value?: string[] | string | null): string[] => { if (value === undefined || value === null) { return []; } if (Array.isArray(value)) { return value; } return [value]; }; /** * Check the value property on the linked * ion-datetime and then format it according * to the locale specified on ion-datetime. */ private setDateTimeText = () => { const { datetimeEl, datetimePresentation } = this; if (!datetimeEl) { return; } const { value, locale, hourCycle, preferWheel, multiple, titleSelectedDatesFormatter } = datetimeEl; const parsedValues = this.getParsedDateValues(value); /** * Both ion-datetime and ion-datetime-button default * to today's date and time if no value is set. */ const parsedDatetimes = parseDate(parsedValues.length > 0 ? parsedValues : [getToday()]); /** * If developers incorrectly use multiple="true" * with non "date" datetimes, then just select * the first value so the interface does * not appear broken. Datetime will provide a * warning in the console. */ const firstParsedDatetime = parsedDatetimes[0]; const use24Hour = is24Hour(locale, hourCycle); // TODO(FW-1865) - Remove once FW-1831 is fixed. parsedDatetimes.forEach((parsedDatetime) => { parsedDatetime.tzOffset = undefined; }); this.dateText = this.timeText = undefined; switch (datetimePresentation) { case 'date-time': case 'time-date': const dateText = getMonthDayAndYear(locale, firstParsedDatetime); const timeText = getLocalizedTime(locale, firstParsedDatetime, use24Hour); if (preferWheel) { this.dateText = `${dateText} ${timeText}`; } else { this.dateText = dateText; this.timeText = timeText; } break; case 'date': if (multiple && parsedValues.length !== 1) { let headerText = `${parsedValues.length} days`; // default/fallback for multiple selection if (titleSelectedDatesFormatter !== undefined) { try { headerText = titleSelectedDatesFormatter(parsedValues); } catch (e) { printIonError('Exception in provided `titleSelectedDatesFormatter`: ', e); } } this.dateText = headerText; } else { this.dateText = getMonthDayAndYear(locale, firstParsedDatetime); } break; case 'time': this.timeText = getLocalizedTime(locale, firstParsedDatetime, use24Hour); break; case 'month-year': this.dateText = getMonthAndYear(locale, firstParsedDatetime); break; case 'month': this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, { month: 'long' }); break; case 'year': this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, { year: 'numeric' }); break; } }; /** * Waits for the ion-datetime to re-render. * This is needed in order to correctly position * a popover relative to the trigger element. */ private waitForDatetimeChanges = async () => { const { datetimeEl } = this; if (!datetimeEl) { return Promise.resolve(); } return new Promise((resolve) => { addEventListener(datetimeEl, 'ionRender', resolve, { once: true }); }); }; private handleDateClick = async (ev: Event) => { const { datetimeEl, datetimePresentation } = this; if (!datetimeEl) { return; } let needsPresentationChange = false; /** * When clicking the date button, * we need to make sure that only a date * picker is displayed. For presentation styles * that display content other than a date picker, * we need to update the presentation style. */ switch (datetimePresentation) { case 'date-time': case 'time-date': const needsChange = datetimeEl.presentation !== 'date'; /** * The date+time wheel picker * shows date and time together, * so do not adjust the presentation * in that case. */ if (!datetimeEl.preferWheel && needsChange) { datetimeEl.presentation = 'date'; needsPresentationChange = true; } break; } /** * Track which button was clicked * so that it can have the correct * activated styles applied when * the modal/popover containing * the datetime is opened. */ this.selectedButton = 'date'; this.presentOverlay(ev, needsPresentationChange, this.dateTargetEl); }; private handleTimeClick = (ev: Event) => { const { datetimeEl, datetimePresentation } = this; if (!datetimeEl) { return; } let needsPresentationChange = false; /** * When clicking the time button, * we need to make sure that only a time * picker is displayed. For presentation styles * that display content other than a time picker, * we need to update the presentation style. */ switch (datetimePresentation) { case 'date-time': case 'time-date': const needsChange = datetimeEl.presentation !== 'time'; if (needsChange) { datetimeEl.presentation = 'time'; needsPresentationChange = true; } break; } /** * Track which button was clicked * so that it can have the correct * activated styles applied when * the modal/popover containing * the datetime is opened. */ this.selectedButton = 'time'; this.presentOverlay(ev, needsPresentationChange, this.timeTargetEl); }; /** * If the datetime is presented in an * overlay, the datetime and overlay * should be appropriately sized. * These classes provide default sizing values * that developers can customize. * The goal is to provide an overlay that is * reasonably sized with a datetime that * fills the entire container. */ private presentOverlay = async (ev: Event, needsPresentationChange: boolean, triggerEl?: HTMLElement) => { const { overlayEl } = this; if (!overlayEl) { return; } if (overlayEl.tagName === 'ION-POPOVER') { /** * When the presentation on datetime changes, * we need to wait for the component to re-render * otherwise the computed width/height of the * popover content will be wrong, causing * the popover to not align with the trigger element. */ if (needsPresentationChange) { await this.waitForDatetimeChanges(); } /** * We pass the trigger button element * so that the popover aligns with the individual * button that was clicked, not the component container. */ (overlayEl as HTMLIonPopoverElement).present({ ...ev, detail: { ionShadowTarget: triggerEl, }, } as CustomEvent); } else { overlayEl.present(); } }; render() { const { color, dateText, timeText, selectedButton, datetimeActive, disabled } = this; const mode = getIonMode(this); return ( {dateText && ( )} {timeText && ( )} ); } }