mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-17 10:41:13 +08:00
447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
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 (
|
|
<Host
|
|
class={createColorClasses(color, {
|
|
[mode]: true,
|
|
[`${selectedButton}-active`]: datetimeActive,
|
|
['datetime-button-disabled']: disabled,
|
|
})}
|
|
>
|
|
{dateText && (
|
|
<button
|
|
class="ion-activatable"
|
|
id="date-button"
|
|
aria-expanded={datetimeActive ? 'true' : 'false'}
|
|
onClick={this.handleDateClick}
|
|
disabled={disabled}
|
|
part="native"
|
|
ref={(el) => (this.dateTargetEl = el)}
|
|
>
|
|
<slot name="date-target">{dateText}</slot>
|
|
{mode === 'md' && <ion-ripple-effect></ion-ripple-effect>}
|
|
</button>
|
|
)}
|
|
|
|
{timeText && (
|
|
<button
|
|
class="ion-activatable"
|
|
id="time-button"
|
|
aria-expanded={datetimeActive ? 'true' : 'false'}
|
|
onClick={this.handleTimeClick}
|
|
disabled={disabled}
|
|
part="native"
|
|
ref={(el) => (this.timeTargetEl = el)}
|
|
>
|
|
<slot name="time-target">{timeText}</slot>
|
|
{mode === 'md' && <ion-ripple-effect></ion-ripple-effect>}
|
|
</button>
|
|
)}
|
|
</Host>
|
|
);
|
|
}
|
|
}
|