Files
sousa-gecko/toolkit/content/widgets/datepicker.js
T
Kagami Sascha Rosylight efe3e03986 Bug 2005495 - Stop updating the picker when the input value is changed r=emilio,webidl
Updating the picker while the user is still deciding their choice is surprising to the user.

Given neither Chrome nor Safari is doing this, it should be safe to unship this behavior.

Differential Revision: https://phabricator.services.mozilla.com/D275977
2025-12-11 18:48:04 +00:00

597 lines
18 KiB
JavaScript

/* 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 = {}) {
if (props.type == "time") {
return;
}
this.context.root.hidden = false;
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();
if (this.props.type !== "datetime-local") {
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("pointerdown", this, { passive: true });
document.addEventListener("mousedown", this);
// Only listen to events in #date-picker (not in a timepicker)
this.context.root.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 "pointerdown": {
if (event.pointerType == "mouse") {
event.target.setPointerCapture(event.pointerId);
}
break;
}
case "mousedown": {
// Use preventDefault to keep focus on input boxes
event.preventDefault();
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 "PickerInit": {
this.init(event.data.detail);
break;
}
}
},
};
/**
* 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.prev,
"date-spinner-month-previous"
);
document.l10n.setAttributes(
this.components.month.elements.next,
"date-spinner-month-next"
);
document.l10n.setAttributes(
this.components.year.elements.prev,
"date-spinner-year-previous"
);
document.l10n.setAttributes(
this.components.year.elements.next,
"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);
},
};
}
document.addEventListener("DOMContentLoaded", () => {
// Create a DatePicker instance and prepare to be initialized
// by the "PickerInit" message.
const root = document.getElementById("date-picker");
new DatePicker({
root,
monthYearNav: root.querySelector(".month-year-nav"),
monthYear: root.querySelector(".month-year"),
monthYearView: root.querySelector(".month-year-view"),
buttonPrev: root.querySelector(".prev"),
buttonNext: root.querySelector(".next"),
weekHeader: root.querySelector(".week-header"),
daysView: root.querySelector(".days-view"),
buttonClear: document.getElementById("clear-button"),
});
});