summaryrefslogtreecommitdiffstats
path: root/toolkit/content/widgets/datepicker.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/content/widgets/datepicker.js')
-rw-r--r--toolkit/content/widgets/datepicker.js597
1 files changed, 597 insertions, 0 deletions
diff --git a/toolkit/content/widgets/datepicker.js b/toolkit/content/widgets/datepicker.js
new file mode 100644
index 0000000000..f771add298
--- /dev/null
+++ b/toolkit/content/widgets/datepicker.js
@@ -0,0 +1,597 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from datekeeper.js */
+/* import-globals-from calendar.js */
+/* import-globals-from spinner.js */
+
+"use strict";
+
+function DatePicker(context) {
+ this.context = context;
+ this._attachEventListeners();
+}
+
+{
+ const CAL_VIEW_SIZE = 42;
+
+ DatePicker.prototype = {
+ /**
+ * Initializes the date picker. Set the default states and properties.
+ * @param {Object} props
+ * {
+ * {Number} year [optional]
+ * {Number} month [optional]
+ * {Number} date [optional]
+ * {Number} min
+ * {Number} max
+ * {Number} step
+ * {Number} stepBase
+ * {Number} firstDayOfWeek
+ * {Array<Number>} weekends
+ * {Array<String>} monthStrings
+ * {Array<String>} weekdayStrings
+ * {String} locale [optional]: User preferred locale
+ * }
+ */
+ init(props = {}) {
+ this.props = props;
+ this._setDefaultState();
+ this._createComponents();
+ this._update();
+ this.components.calendar.focusDay();
+ // TODO(bug 1828721): This is a bit sad.
+ window.PICKER_READY = true;
+ document.dispatchEvent(new CustomEvent("PickerReady"));
+ },
+
+ /*
+ * Set initial date picker states.
+ */
+ _setDefaultState() {
+ const {
+ year,
+ month,
+ day,
+ min,
+ max,
+ step,
+ stepBase,
+ firstDayOfWeek,
+ weekends,
+ monthStrings,
+ weekdayStrings,
+ locale,
+ dir,
+ } = this.props;
+ const dateKeeper = new DateKeeper({
+ year,
+ month,
+ day,
+ min,
+ max,
+ step,
+ stepBase,
+ firstDayOfWeek,
+ weekends,
+ calViewSize: CAL_VIEW_SIZE,
+ });
+
+ document.dir = dir;
+
+ this.state = {
+ dateKeeper,
+ locale,
+ isMonthPickerVisible: false,
+ datetimeOrders: new Intl.DateTimeFormat(locale)
+ .formatToParts(new Date(0))
+ .map(part => part.type),
+ getDayString: day =>
+ day ? new Intl.NumberFormat(locale).format(day) : "",
+ getWeekHeaderString: weekday => weekdayStrings[weekday],
+ getMonthString: month => monthStrings[month],
+ setSelection: date => {
+ dateKeeper.setSelection({
+ year: date.getUTCFullYear(),
+ month: date.getUTCMonth(),
+ day: date.getUTCDate(),
+ });
+ this._update();
+ this._dispatchState();
+ this._closePopup();
+ },
+ setMonthByOffset: offset => {
+ dateKeeper.setMonthByOffset(offset);
+ this._update();
+ },
+ setYear: year => {
+ dateKeeper.setYear(year);
+ dateKeeper.setSelection({
+ year,
+ month: dateKeeper.selection.month,
+ day: dateKeeper.selection.day,
+ });
+ this._update();
+ this._dispatchState();
+ },
+ setMonth: month => {
+ dateKeeper.setMonth(month);
+ dateKeeper.setSelection({
+ year: dateKeeper.selection.year,
+ month,
+ day: dateKeeper.selection.day,
+ });
+ this._update();
+ this._dispatchState();
+ },
+ toggleMonthPicker: () => {
+ this.state.isMonthPickerVisible = !this.state.isMonthPickerVisible;
+ this._update();
+ },
+ };
+ },
+
+ /**
+ * Initalize the date picker components.
+ */
+ _createComponents() {
+ this.components = {
+ calendar: new Calendar(
+ {
+ calViewSize: CAL_VIEW_SIZE,
+ locale: this.state.locale,
+ setSelection: this.state.setSelection,
+ // Year and month could be changed without changing a selection
+ setCalendarMonth: (year, month) => {
+ this.state.dateKeeper.setCalendarMonth({
+ year,
+ month,
+ });
+ this._update();
+ },
+ getDayString: this.state.getDayString,
+ getWeekHeaderString: this.state.getWeekHeaderString,
+ },
+ {
+ weekHeader: this.context.weekHeader,
+ daysView: this.context.daysView,
+ }
+ ),
+ monthYear: new MonthYear(
+ {
+ setYear: this.state.setYear,
+ setMonth: this.state.setMonth,
+ getMonthString: this.state.getMonthString,
+ datetimeOrders: this.state.datetimeOrders,
+ locale: this.state.locale,
+ },
+ {
+ monthYear: this.context.monthYear,
+ monthYearView: this.context.monthYearView,
+ }
+ ),
+ };
+ },
+
+ /**
+ * Update date picker and its components.
+ */
+ _update(options = {}) {
+ const { dateKeeper, isMonthPickerVisible } = this.state;
+
+ const calendarEls = [
+ this.context.buttonPrev,
+ this.context.buttonNext,
+ this.context.weekHeader.parentNode,
+ this.context.buttonClear,
+ ];
+ // Update MonthYear state and toggle visibility for sighted users
+ // and for assistive technology:
+ this.context.monthYearView.hidden = !isMonthPickerVisible;
+ for (let el of calendarEls) {
+ el.hidden = isMonthPickerVisible;
+ }
+ this.context.monthYearNav.toggleAttribute(
+ "monthPickerVisible",
+ isMonthPickerVisible
+ );
+ if (isMonthPickerVisible) {
+ this.state.months = dateKeeper.getMonths();
+ this.state.years = dateKeeper.getYears();
+ } else {
+ this.state.days = dateKeeper.getDays();
+ }
+
+ this.components.monthYear.setProps({
+ isVisible: isMonthPickerVisible,
+ dateObj: dateKeeper.state.dateObj,
+ months: this.state.months,
+ years: this.state.years,
+ toggleMonthPicker: this.state.toggleMonthPicker,
+ noSmoothScroll: options.noSmoothScroll,
+ });
+ this.components.calendar.setProps({
+ isVisible: !isMonthPickerVisible,
+ days: this.state.days,
+ weekHeaders: dateKeeper.state.weekHeaders,
+ });
+ },
+
+ /**
+ * Use postMessage to close the picker.
+ */
+ _closePopup(clear = false) {
+ window.postMessage(
+ {
+ name: "ClosePopup",
+ detail: clear,
+ },
+ "*"
+ );
+ },
+
+ /**
+ * Use postMessage to pass the state of picker to the panel.
+ */
+ _dispatchState() {
+ const { year, month, day } = this.state.dateKeeper.selection;
+
+ // The panel is listening to window for postMessage event, so we
+ // do postMessage to itself to send data to input boxes.
+ window.postMessage(
+ {
+ name: "PickerPopupChanged",
+ detail: {
+ year,
+ month,
+ day,
+ },
+ },
+ "*"
+ );
+ },
+
+ /**
+ * Attach event listeners
+ */
+ _attachEventListeners() {
+ window.addEventListener("message", this);
+ document.addEventListener("mouseup", this, { passive: true });
+ document.addEventListener("mousedown", this);
+ document.addEventListener("keydown", this);
+ },
+
+ /**
+ * Handle events.
+ *
+ * @param {Event} event
+ */
+ handleEvent(event) {
+ switch (event.type) {
+ case "message": {
+ this.handleMessage(event);
+ break;
+ }
+ case "keydown": {
+ switch (event.key) {
+ case "Enter":
+ case " ":
+ case "Escape": {
+ // If the target is a toggle or a spinner on the month-year panel
+ const isOnMonthPicker =
+ this.context.monthYearView.parentNode.contains(event.target);
+
+ if (this.state.isMonthPickerVisible && isOnMonthPicker) {
+ // While a control on the month-year picker panel is focused,
+ // keep the spinner's selection and close the month-year dialog
+ event.stopPropagation();
+ event.preventDefault();
+ this.state.toggleMonthPicker();
+ this.components.calendar.focusDay();
+ break;
+ }
+ if (event.key == "Escape") {
+ // Close the date picker on Escape from within the picker
+ this._closePopup();
+ break;
+ }
+ if (event.target == this.context.buttonPrev) {
+ event.target.classList.add("active");
+ this.state.setMonthByOffset(-1);
+ this.context.buttonPrev.focus();
+ } else if (event.target == this.context.buttonNext) {
+ event.target.classList.add("active");
+ this.state.setMonthByOffset(1);
+ this.context.buttonNext.focus();
+ } else if (event.target == this.context.buttonClear) {
+ event.target.classList.add("active");
+ this._closePopup(/* clear = */ true);
+ }
+ break;
+ }
+ case "Tab": {
+ // Manage tab order of a daysView to prevent keyboard trap
+ if (event.target.tagName === "td") {
+ if (event.shiftKey) {
+ this.context.buttonNext.focus();
+ } else if (!event.shiftKey) {
+ this.context.buttonClear.focus();
+ }
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ break;
+ }
+ }
+ break;
+ }
+ case "mousedown": {
+ // Use preventDefault to keep focus on input boxes
+ event.preventDefault();
+ event.target.setPointerCapture(event.pointerId);
+
+ if (event.target == this.context.buttonClear) {
+ event.target.classList.add("active");
+ this._closePopup(/* clear = */ true);
+ } else if (event.target == this.context.buttonPrev) {
+ event.target.classList.add("active");
+ this.state.dateKeeper.setMonthByOffset(-1);
+ this._update();
+ } else if (event.target == this.context.buttonNext) {
+ event.target.classList.add("active");
+ this.state.dateKeeper.setMonthByOffset(1);
+ this._update();
+ }
+ break;
+ }
+ case "mouseup": {
+ event.target.releasePointerCapture(event.pointerId);
+
+ if (
+ event.target == this.context.buttonPrev ||
+ event.target == this.context.buttonNext
+ ) {
+ event.target.classList.remove("active");
+ }
+ break;
+ }
+ }
+ },
+
+ /**
+ * Handle postMessage events.
+ *
+ * @param {Event} event
+ */
+ handleMessage(event) {
+ switch (event.data.name) {
+ case "PickerSetValue": {
+ this.set(event.data.detail);
+ break;
+ }
+ case "PickerInit": {
+ this.init(event.data.detail);
+ break;
+ }
+ }
+ },
+
+ /**
+ * Set the date state and update the components with the new state.
+ *
+ * @param {Object} dateState
+ * {
+ * {Number} year [optional]
+ * {Number} month [optional]
+ * {Number} date [optional]
+ * }
+ */
+ set({ year, month, day }) {
+ if (!this.state) {
+ return;
+ }
+
+ const { dateKeeper } = this.state;
+
+ dateKeeper.setCalendarMonth({
+ year,
+ month,
+ });
+ dateKeeper.setSelection({
+ year,
+ month,
+ day,
+ });
+ this._update({ noSmoothScroll: true });
+ },
+ };
+
+ /**
+ * MonthYear is a component that handles the month & year spinners
+ *
+ * @param {Object} options
+ * {
+ * {String} locale
+ * {Function} setYear
+ * {Function} setMonth
+ * {Function} getMonthString
+ * {Array<String>} datetimeOrders
+ * }
+ * @param {DOMElement} context
+ */
+ function MonthYear(options, context) {
+ const spinnerSize = 5;
+ const yearFormat = new Intl.DateTimeFormat(options.locale, {
+ year: "numeric",
+ timeZone: "UTC",
+ }).format;
+ const dateFormat = new Intl.DateTimeFormat(options.locale, {
+ year: "numeric",
+ month: "long",
+ timeZone: "UTC",
+ }).format;
+ const spinnerOrder =
+ options.datetimeOrders.indexOf("month") <
+ options.datetimeOrders.indexOf("year")
+ ? "order-month-year"
+ : "order-year-month";
+
+ context.monthYearView.classList.add(spinnerOrder);
+
+ this.context = context;
+ this.state = { dateFormat };
+ this.props = {};
+ this.components = {
+ month: new Spinner(
+ {
+ id: "spinner-month",
+ setValue: month => {
+ this.state.isMonthSet = true;
+ options.setMonth(month);
+ },
+ getDisplayString: options.getMonthString,
+ viewportSize: spinnerSize,
+ },
+ context.monthYearView
+ ),
+ year: new Spinner(
+ {
+ id: "spinner-year",
+ setValue: year => {
+ this.state.isYearSet = true;
+ options.setYear(year);
+ },
+ getDisplayString: year =>
+ yearFormat(new Date(new Date(0).setUTCFullYear(year))),
+ viewportSize: spinnerSize,
+ },
+ context.monthYearView
+ ),
+ };
+
+ this._updateButtonLabels();
+ this._attachEventListeners();
+ }
+
+ MonthYear.prototype = {
+ /**
+ * Set new properties and pass them to components
+ *
+ * @param {Object} props
+ * {
+ * {Boolean} isVisible
+ * {Date} dateObj
+ * {Array<Object>} months
+ * {Array<Object>} years
+ * {Function} toggleMonthPicker
+ * }
+ */
+ setProps(props) {
+ this.context.monthYear.textContent = this.state.dateFormat(props.dateObj);
+ const spinnerDialog = this.context.monthYearView.parentNode;
+
+ if (props.isVisible) {
+ this.context.monthYear.classList.add("active");
+ this.context.monthYear.setAttribute("aria-expanded", "true");
+ // To prevent redundancy, as spinners will announce their value on change
+ this.context.monthYear.setAttribute("aria-live", "off");
+ this.components.month.setState({
+ value: props.dateObj.getUTCMonth(),
+ items: props.months,
+ isInfiniteScroll: true,
+ isValueSet: this.state.isMonthSet,
+ smoothScroll: !(this.state.firstOpened || props.noSmoothScroll),
+ });
+ this.components.year.setState({
+ value: props.dateObj.getUTCFullYear(),
+ items: props.years,
+ isInfiniteScroll: false,
+ isValueSet: this.state.isYearSet,
+ smoothScroll: !(this.state.firstOpened || props.noSmoothScroll),
+ });
+ this.state.firstOpened = false;
+
+ // Set up spinner dialog container properties for assistive technology:
+ spinnerDialog.setAttribute("role", "dialog");
+ spinnerDialog.setAttribute("aria-modal", "true");
+ } else {
+ this.context.monthYear.classList.remove("active");
+ this.context.monthYear.setAttribute("aria-expanded", "false");
+ // To ensure calendar month's changes are announced:
+ this.context.monthYear.setAttribute("aria-live", "polite");
+ // Remove spinner dialog container properties to ensure this hidden
+ // modal will be ignored by assistive technology, because even though
+ // the dialog is hidden, the toggle button is a visible descendant,
+ // so we must not treat its container as a dialog:
+ spinnerDialog.removeAttribute("role");
+ spinnerDialog.removeAttribute("aria-modal");
+ this.state.isMonthSet = false;
+ this.state.isYearSet = false;
+ this.state.firstOpened = true;
+ }
+
+ this.props = Object.assign(this.props, props);
+ },
+
+ /**
+ * Handle events
+ * @param {DOMEvent} event
+ */
+ handleEvent(event) {
+ switch (event.type) {
+ case "click": {
+ this.props.toggleMonthPicker();
+ break;
+ }
+ case "keydown": {
+ if (event.key === "Enter" || event.key === " ") {
+ event.stopPropagation();
+ event.preventDefault();
+ this.props.toggleMonthPicker();
+ }
+ break;
+ }
+ }
+ },
+
+ /**
+ * Update localizable IDs of the spinner and its Prev/Next buttons
+ */
+ _updateButtonLabels() {
+ document.l10n.setAttributes(
+ this.components.month.elements.spinner,
+ "date-spinner-month"
+ );
+ document.l10n.setAttributes(
+ this.components.year.elements.spinner,
+ "date-spinner-year"
+ );
+ document.l10n.setAttributes(
+ this.components.month.elements.up,
+ "date-spinner-month-previous"
+ );
+ document.l10n.setAttributes(
+ this.components.month.elements.down,
+ "date-spinner-month-next"
+ );
+ document.l10n.setAttributes(
+ this.components.year.elements.up,
+ "date-spinner-year-previous"
+ );
+ document.l10n.setAttributes(
+ this.components.year.elements.down,
+ "date-spinner-year-next"
+ );
+ document.l10n.translateRoots();
+ },
+
+ /**
+ * Attach event listener to monthYear button
+ */
+ _attachEventListeners() {
+ this.context.monthYear.addEventListener("click", this);
+ this.context.monthYear.addEventListener("keydown", this);
+ },
+ };
+}