diff options
Diffstat (limited to 'toolkit')
71 files changed, 4934 insertions, 524 deletions
diff --git a/toolkit/components/autocomplete/nsAutoCompleteController.cpp b/toolkit/components/autocomplete/nsAutoCompleteController.cpp index 5d69ea1a3..9ca382fe5 100644 --- a/toolkit/components/autocomplete/nsAutoCompleteController.cpp +++ b/toolkit/components/autocomplete/nsAutoCompleteController.cpp @@ -1637,58 +1637,72 @@ nsAutoCompleteController::ProcessResult(int32_t aSearchIndex, nsIAutoCompleteRes MOZ_ASSERT(mResults.Count() >= aSearchIndex + 1, "aSearchIndex should always be valid for mResults"); - uint32_t oldRowCount = mRowCount; - // If the search failed, increase the match count to include the error - // description. - if (searchResult == nsIAutoCompleteResult::RESULT_FAILURE) { - nsAutoString error; - aResult->GetErrorDescription(error); - if (!error.IsEmpty()) { - ++mRowCount; - if (mTree) { - mTree->RowCountChanged(oldRowCount, 1); + bool isTypeAheadResult = false; + aResult->GetTypeAheadResult(&isTypeAheadResult); + + if (!isTypeAheadResult) { + uint32_t oldRowCount = mRowCount; + // If the search failed, increase the match count to include the error + // description. + if (searchResult == nsIAutoCompleteResult::RESULT_FAILURE) { + nsAutoString error; + aResult->GetErrorDescription(error); + if (!error.IsEmpty()) { + ++mRowCount; + if (mTree) { + mTree->RowCountChanged(oldRowCount, 1); + } } - } - } else if (searchResult == nsIAutoCompleteResult::RESULT_SUCCESS || - searchResult == nsIAutoCompleteResult::RESULT_SUCCESS_ONGOING) { - // Increase the match count for all matches in this result. - uint32_t totalMatchCount = 0; - for (uint32_t i = 0; i < mResults.Length(); i++) { - nsIAutoCompleteResult* result = mResults.SafeObjectAt(i); - if (result) { - uint32_t matchCount = 0; - result->GetMatchCount(&matchCount); - totalMatchCount += matchCount; + } else if (searchResult == nsIAutoCompleteResult::RESULT_SUCCESS || + searchResult == nsIAutoCompleteResult::RESULT_SUCCESS_ONGOING) { + // Increase the match count for all matches in this result. + uint32_t totalMatchCount = 0; + for (uint32_t i = 0; i < mResults.Length(); i++) { + nsIAutoCompleteResult* result = mResults.SafeObjectAt(i); + if (result) { + // not all results implement this, so it can likely fail. + bool typeAhead = false; + result->GetTypeAheadResult(&typeAhead); + if (!typeAhead) { + uint32_t matchCount = 0; + result->GetMatchCount(&matchCount); + totalMatchCount += matchCount; + } + } } - } - uint32_t delta = totalMatchCount - oldRowCount; + uint32_t delta = totalMatchCount - oldRowCount; - mRowCount += delta; - if (mTree) { - mTree->RowCountChanged(oldRowCount, delta); + mRowCount += delta; + if (mTree) { + mTree->RowCountChanged(oldRowCount, delta); + } } - } - // Try to autocomplete the default index for this search. - // Do this before invalidating so the binding knows about it. - CompleteDefaultIndex(aSearchIndex); + // Try to autocomplete the default index for this search. + // Do this before invalidating so the binding knows about it. + CompleteDefaultIndex(aSearchIndex); - // Refresh the popup view to display the new search results - nsCOMPtr<nsIAutoCompletePopup> popup; - input->GetPopup(getter_AddRefs(popup)); - NS_ENSURE_TRUE(popup != nullptr, NS_ERROR_FAILURE); - popup->Invalidate(nsIAutoCompletePopup::INVALIDATE_REASON_NEW_RESULT); + // Refresh the popup view to display the new search results + nsCOMPtr<nsIAutoCompletePopup> popup; + input->GetPopup(getter_AddRefs(popup)); + NS_ENSURE_TRUE(popup != nullptr, NS_ERROR_FAILURE); + popup->Invalidate(nsIAutoCompletePopup::INVALIDATE_REASON_NEW_RESULT); - uint32_t minResults; - input->GetMinResultsForPopup(&minResults); + uint32_t minResults; + input->GetMinResultsForPopup(&minResults); - // Make sure the popup is open, if necessary, since we now have at least one - // search result ready to display. Don't force the popup closed if we might - // get results in the future to avoid unnecessarily canceling searches. - if (mRowCount || !minResults) { - OpenPopup(); - } else if (mSearchesOngoing == 0) { - ClosePopup(); + // Make sure the popup is open, if necessary, since we now have at least one + // search result ready to display. Don't force the popup closed if we might + // get results in the future to avoid unnecessarily canceling searches. + if (mRowCount || !minResults) { + OpenPopup(); + } else if (mSearchesOngoing == 0) { + ClosePopup(); + } + } else if (searchResult == nsIAutoCompleteResult::RESULT_SUCCESS || + searchResult == nsIAutoCompleteResult::RESULT_SUCCESS_ONGOING) { + // Try to autocomplete the default index for this search. + CompleteDefaultIndex(aSearchIndex); } return NS_OK; @@ -2033,14 +2047,20 @@ nsAutoCompleteController::RowIndexToSearch(int32_t aRowIndex, int32_t *aSearchIn uint32_t rowCount = 0; - uint16_t searchResult; - result->GetSearchResult(&searchResult); + // Skip past the result completely if it is marked as hidden + bool isTypeAheadResult = false; + result->GetTypeAheadResult(&isTypeAheadResult); - // Find out how many results were provided by the - // current nsIAutoCompleteSearch. - if (searchResult == nsIAutoCompleteResult::RESULT_SUCCESS || - searchResult == nsIAutoCompleteResult::RESULT_SUCCESS_ONGOING) { - result->GetMatchCount(&rowCount); + if (!isTypeAheadResult) { + uint16_t searchResult; + result->GetSearchResult(&searchResult); + + // Find out how many results were provided by the + // current nsIAutoCompleteSearch. + if (searchResult == nsIAutoCompleteResult::RESULT_SUCCESS || + searchResult == nsIAutoCompleteResult::RESULT_SUCCESS_ONGOING) { + result->GetMatchCount(&rowCount); + } } // If the given row index is within the results range diff --git a/toolkit/components/autocomplete/nsAutoCompleteSimpleResult.cpp b/toolkit/components/autocomplete/nsAutoCompleteSimpleResult.cpp index 9fd2c0022..683ac462a 100644 --- a/toolkit/components/autocomplete/nsAutoCompleteSimpleResult.cpp +++ b/toolkit/components/autocomplete/nsAutoCompleteSimpleResult.cpp @@ -43,7 +43,8 @@ struct AutoCompleteSimpleResultMatch nsAutoCompleteSimpleResult::nsAutoCompleteSimpleResult() : mDefaultIndex(-1), - mSearchResult(RESULT_NOMATCH) + mSearchResult(RESULT_NOMATCH), + mTypeAheadResult(false) { } @@ -66,6 +67,12 @@ nsAutoCompleteSimpleResult::AppendResult(nsIAutoCompleteResult* aResult) mErrorDescription = errorDescription; } + bool typeAheadResult = false; + if (NS_SUCCEEDED(aResult->GetTypeAheadResult(&typeAheadResult)) && + typeAheadResult) { + mTypeAheadResult = typeAheadResult; + } + int32_t defaultIndex = -1; if (NS_SUCCEEDED(aResult->GetDefaultIndex(&defaultIndex)) && defaultIndex >= 0) { @@ -166,6 +173,20 @@ nsAutoCompleteSimpleResult::SetErrorDescription( return NS_OK; } +// typeAheadResult +NS_IMETHODIMP +nsAutoCompleteSimpleResult::GetTypeAheadResult(bool *aTypeAheadResult) +{ + *aTypeAheadResult = mTypeAheadResult; + return NS_OK; +} +NS_IMETHODIMP +nsAutoCompleteSimpleResult::SetTypeAheadResult(bool aTypeAheadResult) +{ + mTypeAheadResult = aTypeAheadResult; + return NS_OK; +} + NS_IMETHODIMP nsAutoCompleteSimpleResult::InsertMatchAt(int32_t aIndex, const nsAString& aValue, diff --git a/toolkit/components/autocomplete/nsAutoCompleteSimpleResult.h b/toolkit/components/autocomplete/nsAutoCompleteSimpleResult.h index 28968aa57..61ee542e4 100644 --- a/toolkit/components/autocomplete/nsAutoCompleteSimpleResult.h +++ b/toolkit/components/autocomplete/nsAutoCompleteSimpleResult.h @@ -38,6 +38,8 @@ protected: int32_t mDefaultIndex; uint32_t mSearchResult; + bool mTypeAheadResult; + nsCOMPtr<nsIAutoCompleteSimpleResultListener> mListener; }; diff --git a/toolkit/components/autocomplete/nsIAutoCompleteResult.idl b/toolkit/components/autocomplete/nsIAutoCompleteResult.idl index c719d9427..9ae22ade7 100644 --- a/toolkit/components/autocomplete/nsIAutoCompleteResult.idl +++ b/toolkit/components/autocomplete/nsIAutoCompleteResult.idl @@ -50,6 +50,13 @@ interface nsIAutoCompleteResult : nsISupports readonly attribute unsigned long matchCount; /** + * If true, the results will not be displayed in the popup. However, + * if a default index is specified, the default item will still be + * completed in the input. + */ + readonly attribute boolean typeAheadResult; + + /** * Get the value of the result at the given index */ AString getValueAt(in long index); diff --git a/toolkit/components/autocomplete/nsIAutoCompleteSimpleResult.idl b/toolkit/components/autocomplete/nsIAutoCompleteSimpleResult.idl index 5e92e037a..6a8827ab8 100644 --- a/toolkit/components/autocomplete/nsIAutoCompleteSimpleResult.idl +++ b/toolkit/components/autocomplete/nsIAutoCompleteSimpleResult.idl @@ -42,6 +42,12 @@ interface nsIAutoCompleteSimpleResult : nsIAutoCompleteResult void setSearchResult(in unsigned short aSearchResult); /** + * A writer for the readonly attribute 'typeAheadResult', typically set + * because a result is only intended for type-ahead completion. + */ + void setTypeAheadResult(in boolean aHidden); + + /** * Inserts a match consisting of the given value, comment, image, style and * the value to use for defaultIndex completion at a given position. * @param aIndex diff --git a/toolkit/components/autocomplete/tests/unit/head_autocomplete.js b/toolkit/components/autocomplete/tests/unit/head_autocomplete.js index 1443879f0..5a458bdf4 100644 --- a/toolkit/components/autocomplete/tests/unit/head_autocomplete.js +++ b/toolkit/components/autocomplete/tests/unit/head_autocomplete.js @@ -85,6 +85,11 @@ AutoCompleteResultBase.prototype = { defaultIndex: -1, + _typeAheadResult: false, + get typeAheadResult() { + return this._typeAheadResult; + }, + get matchCount() { return this._values.length; }, diff --git a/toolkit/components/autocomplete/tests/unit/test_hiddenResult.js b/toolkit/components/autocomplete/tests/unit/test_hiddenResult.js new file mode 100644 index 000000000..8e2485716 --- /dev/null +++ b/toolkit/components/autocomplete/tests/unit/test_hiddenResult.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function AutoCompleteResult(aValues) { + this._values = aValues; + this.defaultIndex = -1; + this._typeAheadResult = false; +} +AutoCompleteResult.prototype = Object.create(AutoCompleteResultBase.prototype); + +function AutoCompleteTypeAheadResult(aValues) { + this._values = aValues; + this.defaultIndex = 0; + this._typeAheadResult = true; +} +AutoCompleteTypeAheadResult.prototype = Object.create(AutoCompleteResultBase.prototype); + + +/** + * Test AutoComplete with multiple AutoCompleteSearch sources, with one of them + * being hidden from the popup, but can still do typeahead completion. + */ +function run_test() { + do_test_pending(); + + var inputStr = "moz"; + + // Type ahead result + var searchTypeAhead = new AutoCompleteSearchBase("search1", + new AutoCompleteTypeAheadResult(["mozillaTest1"])); + // Regular result + var searchNormal = new AutoCompleteSearchBase("search2", + new AutoCompleteResult(["mozillaTest2"])); + + // Register searches so AutoCompleteController can find them + registerAutoCompleteSearch(searchNormal); + registerAutoCompleteSearch(searchTypeAhead); + + // Make an AutoCompleteInput that uses our searches + // and confirms results on search complete. + var input = new AutoCompleteInputBase([searchTypeAhead.name, searchNormal.name]); + input.completeDefaultIndex = true; + input.textValue = inputStr; + + // Caret must be at the end. Autofill doesn't happen unless you're typing + // characters at the end. + var strLen = inputStr.length; + input.selectTextRange(strLen, strLen); + do_check_eq(input.selectionStart, strLen); + do_check_eq(input.selectionEnd, strLen); + + var controller = Cc["@mozilla.org/autocomplete/controller;1"]. + getService(Ci.nsIAutoCompleteController); + + controller.input = input; + controller.startSearch(inputStr); + + input.onSearchComplete = function() { + // Hidden results should still be able to do inline autocomplete + do_check_eq(input.textValue, "mozillaTest1"); + + // Now, let's fill the textbox with the first result of the popup. + // The first search is marked as hidden, so we must always get the + // second search. + controller.handleEnter(true); + do_check_eq(input.textValue, "mozillaTest2"); + + // Only one item in the popup. + do_check_eq(controller.matchCount, 1); + + // Unregister searches + unregisterAutoCompleteSearch(searchNormal); + unregisterAutoCompleteSearch(searchTypeAhead); + do_test_finished(); + }; +} diff --git a/toolkit/components/autocomplete/tests/unit/test_popupSelectionVsDefaultCompleteValue.js b/toolkit/components/autocomplete/tests/unit/test_popupSelectionVsDefaultCompleteValue.js new file mode 100644 index 000000000..fb4153355 --- /dev/null +++ b/toolkit/components/autocomplete/tests/unit/test_popupSelectionVsDefaultCompleteValue.js @@ -0,0 +1,71 @@ +/* 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/. */ + +function AutoCompleteTypeAheadResult(aValues, aFinalCompleteValues) { + this._values = aValues; + this._finalCompleteValues = aFinalCompleteValues; + this.defaultIndex = 0; + this._typeAheadResult = true; +} +AutoCompleteTypeAheadResult.prototype = Object.create(AutoCompleteResultBase.prototype); + +function AutoCompleteResult(aValues) { + this._values = aValues; +} +AutoCompleteResult.prototype = Object.create(AutoCompleteResultBase.prototype); + +function AutoCompleteInput(aSearches) { + this.searches = aSearches; + this.popupOpen = true; + this.completeDefaultIndex = true; + this.completeSelectedIndex = true; +} +AutoCompleteInput.prototype = Object.create(AutoCompleteInputBase.prototype); + +function run_test() { + run_next_test(); +} + +add_test(function test_handleEnter() { + doSearch("moz", function(aController) { + do_check_eq(aController.input.textValue, "mozilla.com"); + aController.handleEnter(true); + do_check_eq(aController.input.textValue, "mozilla.org"); + }); +}); + +function doSearch(aSearchString, aOnCompleteCallback) { + let typeAheadSearch = new AutoCompleteSearchBase( + "typeAheadSearch", + new AutoCompleteTypeAheadResult([ "mozilla.com" ], [ "http://www.mozilla.com" ]) + ); + registerAutoCompleteSearch(typeAheadSearch); + + let search = new AutoCompleteSearchBase( + "search", + new AutoCompleteResult([ "mozilla.org" ]) + ); + registerAutoCompleteSearch(search); + + let controller = Cc["@mozilla.org/autocomplete/controller;1"]. + getService(Ci.nsIAutoCompleteController); + + // Make an AutoCompleteInput that uses our searches and confirms results. + let input = new AutoCompleteInput([ typeAheadSearch.name, search.name ]); + input.textValue = aSearchString; + + // Caret must be at the end for autofill to happen. + let strLen = aSearchString.length; + input.selectTextRange(strLen, strLen); + controller.input = input; + controller.startSearch(aSearchString); + + input.onSearchComplete = function onSearchComplete() { + aOnCompleteCallback(controller); + + // Clean up. + unregisterAutoCompleteSearch(search); + run_next_test(); + }; +} diff --git a/toolkit/components/autocomplete/tests/unit/xpcshell.ini b/toolkit/components/autocomplete/tests/unit/xpcshell.ini index 4d193965c..daf89db17 100644 --- a/toolkit/components/autocomplete/tests/unit/xpcshell.ini +++ b/toolkit/components/autocomplete/tests/unit/xpcshell.ini @@ -18,7 +18,9 @@ tail = [test_finalCompleteValue_forceComplete.js] [test_finalCompleteValueSelectedIndex.js] [test_finalDefaultCompleteValue.js] +[test_hiddenResult.js] [test_immediate_search.js] [test_insertMatchAt.js] +[test_popupSelectionVsDefaultCompleteValue.js] [test_previousResult.js] [test_stopSearch.js] diff --git a/toolkit/components/filepicker/nsFileView.cpp b/toolkit/components/filepicker/nsFileView.cpp index ad4471e86..9a8278496 100644 --- a/toolkit/components/filepicker/nsFileView.cpp +++ b/toolkit/components/filepicker/nsFileView.cpp @@ -133,6 +133,13 @@ NS_IMETHODIMP nsFileResult::GetMatchCount(uint32_t *aMatchCount) return NS_OK; } +NS_IMETHODIMP nsFileResult::GetTypeAheadResult(bool *aTypeAheadResult) +{ + NS_ENSURE_ARG_POINTER(aTypeAheadResult); + *aTypeAheadResult = false; + return NS_OK; +} + NS_IMETHODIMP nsFileResult::GetValueAt(int32_t index, nsAString & aValue) { aValue = mValues[index]; diff --git a/toolkit/components/mozintl/MozIntl.cpp b/toolkit/components/mozintl/MozIntl.cpp index 9c393c296..9c61c73a6 100644 --- a/toolkit/components/mozintl/MozIntl.cpp +++ b/toolkit/components/mozintl/MozIntl.cpp @@ -48,6 +48,32 @@ MozIntl::AddGetCalendarInfo(JS::Handle<JS::Value> val, JSContext* cx) return NS_OK; } +NS_IMETHODIMP +MozIntl::AddGetDisplayNames(JS::Handle<JS::Value> val, JSContext* cx) +{ + if (!val.isObject()) { + return NS_ERROR_INVALID_ARG; + } + + JS::Rooted<JSObject*> realIntlObj(cx, js::CheckedUnwrap(&val.toObject())); + if (!realIntlObj) { + return NS_ERROR_INVALID_ARG; + } + + JSAutoCompartment ac(cx, realIntlObj); + + static const JSFunctionSpec funcs[] = { + JS_SELF_HOSTED_FN("getDisplayNames", "Intl_getDisplayNames", 2, 0), + JS_FS_END + }; + + if (!JS_DefineFunctions(cx, realIntlObj, funcs)) { + return NS_ERROR_FAILURE; + } + + return NS_OK; +} + NS_GENERIC_FACTORY_CONSTRUCTOR(MozIntl) NS_DEFINE_NAMED_CID(MOZ_MOZINTL_CID); diff --git a/toolkit/components/mozintl/mozIMozIntl.idl b/toolkit/components/mozintl/mozIMozIntl.idl index 67be184d4..f28824d47 100644 --- a/toolkit/components/mozintl/mozIMozIntl.idl +++ b/toolkit/components/mozintl/mozIMozIntl.idl @@ -9,4 +9,5 @@ interface mozIMozIntl : nsISupports { [implicit_jscontext] void addGetCalendarInfo(in jsval intlObject); + [implicit_jscontext] void addGetDisplayNames(in jsval intlObject); }; diff --git a/toolkit/components/mozintl/test/test_mozintl.js b/toolkit/components/mozintl/test/test_mozintl.js index 0eca2c67e..8d2720bf0 100644 --- a/toolkit/components/mozintl/test/test_mozintl.js +++ b/toolkit/components/mozintl/test/test_mozintl.js @@ -7,6 +7,7 @@ function run_test() { test_this_global(mozIntl); test_cross_global(mozIntl); + test_methods_presence(mozIntl); ok(true); } @@ -30,3 +31,16 @@ function test_cross_global(mozIntl) { equal(waivedX.getCalendarInfo() instanceof Object, false); equal(waivedX.getCalendarInfo() instanceof global.Object, true); } + +function test_methods_presence(mozIntl) { + equal(mozIntl.addGetCalendarInfo instanceof Function, true); + equal(mozIntl.addGetDisplayNames instanceof Function, true); + + let x = {}; + + mozIntl.addGetCalendarInfo(x); + equal(x.getCalendarInfo instanceof Function, true); + + mozIntl.addGetDisplayNames(x); + equal(x.getDisplayNames instanceof Function, true); +} diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_search.js b/toolkit/components/passwordmgr/test/unit/test_logins_search.js index 188c75039..730771981 100644 --- a/toolkit/components/passwordmgr/test/unit/test_logins_search.js +++ b/toolkit/components/passwordmgr/test/unit/test_logins_search.js @@ -192,7 +192,6 @@ add_task(function test_search_all_full_case_sensitive() { checkAllSearches({ hostname: "http://www.example.com" }, 1); checkAllSearches({ hostname: "http://www.example.com/" }, 0); - checkAllSearches({ hostname: "http://" }, 0); checkAllSearches({ hostname: "example.com" }, 0); checkAllSearches({ formSubmitURL: "http://www.example.com" }, 2); diff --git a/toolkit/components/places/UnifiedComplete.js b/toolkit/components/places/UnifiedComplete.js index ad3d35aab..acd358b11 100644 --- a/toolkit/components/places/UnifiedComplete.js +++ b/toolkit/components/places/UnifiedComplete.js @@ -1245,7 +1245,7 @@ Search.prototype = { // * If the protocol differs we should not match. For example if the user // searched https we should not return http. try { - let prefixURI = NetUtil.newURI(this._strippedPrefix); + let prefixURI = NetUtil.newURI(this._strippedPrefix + match.token); let finalURI = NetUtil.newURI(match.url); if (prefixURI.scheme != finalURI.scheme) return false; diff --git a/toolkit/components/places/moz.build b/toolkit/components/places/moz.build index adac79cba..85e1e93e1 100644 --- a/toolkit/components/places/moz.build +++ b/toolkit/components/places/moz.build @@ -78,6 +78,8 @@ if CONFIG['MOZ_PLACES']: EXTRA_COMPONENTS += [ 'ColorAnalyzer.js', 'nsLivemarkService.js', + 'nsPlacesAutoComplete.js', + 'nsPlacesAutoComplete.manifest', 'nsPlacesExpiration.js', 'nsTaggingService.js', 'PageIconProtocolHandler.js', diff --git a/toolkit/components/places/nsNavHistory.cpp b/toolkit/components/places/nsNavHistory.cpp index 8cf3a2e32..7f4007c1a 100644 --- a/toolkit/components/places/nsNavHistory.cpp +++ b/toolkit/components/places/nsNavHistory.cpp @@ -949,6 +949,10 @@ nsresult // static nsNavHistory::AsciiHostNameFromHostString(const nsACString& aHostName, nsACString& aAscii) { + aAscii.Truncate(); + if (aHostName.IsEmpty()) { + return NS_OK; + } // To properly generate a uri we must provide a protocol. nsAutoCString fakeURL("http://"); fakeURL.Append(aHostName); diff --git a/toolkit/components/places/nsPlacesAutoComplete.js b/toolkit/components/places/nsPlacesAutoComplete.js new file mode 100644 index 000000000..29bdae4c1 --- /dev/null +++ b/toolkit/components/places/nsPlacesAutoComplete.js @@ -0,0 +1,1778 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 sts=2 expandtab + * 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/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch", + "resource://gre/modules/TelemetryStopwatch.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); + +//////////////////////////////////////////////////////////////////////////////// +//// Constants + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; + +// This SQL query fragment provides the following: +// - whether the entry is bookmarked (kQueryIndexBookmarked) +// - the bookmark title, if it is a bookmark (kQueryIndexBookmarkTitle) +// - the tags associated with a bookmarked entry (kQueryIndexTags) +const kBookTagSQLFragment = + `EXISTS(SELECT 1 FROM moz_bookmarks WHERE fk = h.id) AS bookmarked, + ( + SELECT title FROM moz_bookmarks WHERE fk = h.id AND title NOTNULL + ORDER BY lastModified DESC LIMIT 1 + ) AS btitle, + ( + SELECT GROUP_CONCAT(t.title, ',') + FROM moz_bookmarks b + JOIN moz_bookmarks t ON t.id = +b.parent AND t.parent = :parent + WHERE b.fk = h.id + ) AS tags`; + +// observer topics +const kTopicShutdown = "places-shutdown"; +const kPrefChanged = "nsPref:changed"; + +// Match type constants. These indicate what type of search function we should +// be using. +const MATCH_ANYWHERE = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE; +const MATCH_BOUNDARY_ANYWHERE = Ci.mozIPlacesAutoComplete.MATCH_BOUNDARY_ANYWHERE; +const MATCH_BOUNDARY = Ci.mozIPlacesAutoComplete.MATCH_BOUNDARY; +const MATCH_BEGINNING = Ci.mozIPlacesAutoComplete.MATCH_BEGINNING; +const MATCH_BEGINNING_CASE_SENSITIVE = Ci.mozIPlacesAutoComplete.MATCH_BEGINNING_CASE_SENSITIVE; + +// AutoComplete index constants. All AutoComplete queries will provide these +// columns in this order. +const kQueryIndexURL = 0; +const kQueryIndexTitle = 1; +const kQueryIndexFaviconURL = 2; +const kQueryIndexBookmarked = 3; +const kQueryIndexBookmarkTitle = 4; +const kQueryIndexTags = 5; +const kQueryIndexVisitCount = 6; +const kQueryIndexTyped = 7; +const kQueryIndexPlaceId = 8; +const kQueryIndexQueryType = 9; +const kQueryIndexOpenPageCount = 10; + +// AutoComplete query type constants. Describes the various types of queries +// that we can process. +const kQueryTypeKeyword = 0; +const kQueryTypeFiltered = 1; + +// This separator is used as an RTL-friendly way to split the title and tags. +// It can also be used by an nsIAutoCompleteResult consumer to re-split the +// "comment" back into the title and the tag. +const kTitleTagsSeparator = " \u2013 "; + +const kBrowserUrlbarBranch = "browser.urlbar."; +// Toggle autocomplete. +const kBrowserUrlbarAutocompleteEnabledPref = "autocomplete.enabled"; +// Toggle autoFill. +const kBrowserUrlbarAutofillPref = "autoFill"; +// Whether to search only typed entries. +const kBrowserUrlbarAutofillTypedPref = "autoFill.typed"; + +// The Telemetry histogram for urlInlineComplete query on domain +const DOMAIN_QUERY_TELEMETRY = "PLACES_AUTOCOMPLETE_URLINLINE_DOMAIN_QUERY_TIME_MS"; + +//////////////////////////////////////////////////////////////////////////////// +//// Globals + +XPCOMUtils.defineLazyServiceGetter(this, "gTextURIService", + "@mozilla.org/intl/texttosuburi;1", + "nsITextToSubURI"); + +//////////////////////////////////////////////////////////////////////////////// +//// Helpers + +/** + * Initializes our temporary table on a given database. + * + * @param aDatabase + * The mozIStorageConnection to set up the temp table on. + */ +function initTempTable(aDatabase) +{ + // Note: this should be kept up-to-date with the definition in + // nsPlacesTables.h. + let stmt = aDatabase.createAsyncStatement( + `CREATE TEMP TABLE moz_openpages_temp ( + url TEXT PRIMARY KEY + , open_count INTEGER + )` + ); + stmt.executeAsync(); + stmt.finalize(); + + // Note: this should be kept up-to-date with the definition in + // nsPlacesTriggers.h. + stmt = aDatabase.createAsyncStatement( + `CREATE TEMPORARY TRIGGER moz_openpages_temp_afterupdate_trigger + AFTER UPDATE OF open_count ON moz_openpages_temp FOR EACH ROW + WHEN NEW.open_count = 0 + BEGIN + DELETE FROM moz_openpages_temp + WHERE url = NEW.url; + END` + ); + stmt.executeAsync(); + stmt.finalize(); +} + +/** + * Used to unescape encoded URI strings, and drop information that we do not + * care about for searching. + * + * @param aURIString + * The text to unescape and modify. + * @return the modified uri. + */ +function fixupSearchText(aURIString) +{ + let uri = stripPrefix(aURIString); + return gTextURIService.unEscapeURIForUI("UTF-8", uri); +} + +/** + * Strip prefixes from the URI that we don't care about for searching. + * + * @param aURIString + * The text to modify. + * @return the modified uri. + */ +function stripPrefix(aURIString) +{ + let uri = aURIString; + + if (uri.indexOf("http://") == 0) { + uri = uri.slice(7); + } + else if (uri.indexOf("https://") == 0) { + uri = uri.slice(8); + } + else if (uri.indexOf("ftp://") == 0) { + uri = uri.slice(6); + } + + if (uri.indexOf("www.") == 0) { + uri = uri.slice(4); + } + return uri; +} + +/** + * safePrefGetter get the pref with type safety. + * This will return the default value provided if no pref is set. + * + * @param aPrefBranch + * The nsIPrefBranch containing the required preference + * @param aName + * A preference name + * @param aDefault + * The preference's default value + * @return the preference value or provided default + */ + +function safePrefGetter(aPrefBranch, aName, aDefault) { + let types = { + boolean: "Bool", + number: "Int", + string: "Char" + }; + let type = types[typeof(aDefault)]; + if (!type) { + throw "Unknown type!"; + } + + // If the pref isn't set, we want to use the default. + if (aPrefBranch.getPrefType(aName) == Ci.nsIPrefBranch.PREF_INVALID) { + return aDefault; + } + try { + return aPrefBranch["get" + type + "Pref"](aName); + } + catch (e) { + return aDefault; + } +} + +/** + * Whether UnifiedComplete is alive. + */ +function isUnifiedCompleteInstantiated() { + try { + return Components.manager.QueryInterface(Ci.nsIServiceManager) + .isServiceInstantiated(Cc["@mozilla.org/autocomplete/search;1?name=unifiedcomplete"], + Ci.mozIPlacesAutoComplete); + } catch (ex) { + return false; + } +} + +//////////////////////////////////////////////////////////////////////////////// +//// AutoCompleteStatementCallbackWrapper class + +/** + * Wraps a callback and ensures that handleCompletion is not dispatched if the + * query is no longer tracked. + * + * @param aAutocomplete + * A reference to a nsPlacesAutoComplete. + * @param aCallback + * A reference to a mozIStorageStatementCallback + * @param aDBConnection + * The database connection to execute the queries on. + */ +function AutoCompleteStatementCallbackWrapper(aAutocomplete, aCallback, + aDBConnection) +{ + this._autocomplete = aAutocomplete; + this._callback = aCallback; + this._db = aDBConnection; +} + +AutoCompleteStatementCallbackWrapper.prototype = { + ////////////////////////////////////////////////////////////////////////////// + //// mozIStorageStatementCallback + + handleResult: function ACSCW_handleResult(aResultSet) + { + this._callback.handleResult.apply(this._callback, arguments); + }, + + handleError: function ACSCW_handleError(aError) + { + this._callback.handleError.apply(this._callback, arguments); + }, + + handleCompletion: function ACSCW_handleCompletion(aReason) + { + // Only dispatch handleCompletion if we are not done searching and are a + // pending search. + if (!this._autocomplete.isSearchComplete() && + this._autocomplete.isPendingSearch(this._handle)) { + this._callback.handleCompletion.apply(this._callback, arguments); + } + }, + + ////////////////////////////////////////////////////////////////////////////// + //// AutoCompleteStatementCallbackWrapper + + /** + * Executes the specified query asynchronously. This object will notify + * this._callback if we should notify (logic explained in handleCompletion). + * + * @param aQueries + * The queries to execute asynchronously. + * @return a mozIStoragePendingStatement that can be used to cancel the + * queries. + */ + executeAsync: function ACSCW_executeAsync(aQueries) + { + return this._handle = this._db.executeAsync(aQueries, aQueries.length, + this); + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsISupports + + QueryInterface: XPCOMUtils.generateQI([ + Ci.mozIStorageStatementCallback, + ]) +}; + +//////////////////////////////////////////////////////////////////////////////// +//// nsPlacesAutoComplete class +//// @mozilla.org/autocomplete/search;1?name=history + +function nsPlacesAutoComplete() +{ + ////////////////////////////////////////////////////////////////////////////// + //// Shared Constants for Smart Getters + + // TODO bug 412736 in case of a frecency tie, break it with h.typed and + // h.visit_count which is better than nothing. This is slow, so not doing it + // yet... + function baseQuery(conditions = "") { + let query = `SELECT h.url, h.title, f.url, ${kBookTagSQLFragment}, + h.visit_count, h.typed, h.id, :query_type, + t.open_count + FROM moz_places h + LEFT JOIN moz_favicons f ON f.id = h.favicon_id + LEFT JOIN moz_openpages_temp t ON t.url = h.url + WHERE h.frecency <> 0 + AND AUTOCOMPLETE_MATCH(:searchString, h.url, + IFNULL(btitle, h.title), tags, + h.visit_count, h.typed, + bookmarked, t.open_count, + :matchBehavior, :searchBehavior) + ${conditions} + ORDER BY h.frecency DESC, h.id DESC + LIMIT :maxResults`; + return query; + } + + ////////////////////////////////////////////////////////////////////////////// + //// Smart Getters + + XPCOMUtils.defineLazyGetter(this, "_db", function() { + // Get a cloned, read-only version of the database. We'll only ever write + // to our own in-memory temp table, and having a cloned copy means we do not + // run the risk of our queries taking longer due to the main database + // connection performing a long-running task. + let db = PlacesUtils.history.DBConnection.clone(true); + + // Autocomplete often fallbacks to a table scan due to lack of text indices. + // In such cases a larger cache helps reducing IO. The default Storage + // value is MAX_CACHE_SIZE_BYTES in storage/mozStorageConnection.cpp. + let stmt = db.createAsyncStatement("PRAGMA cache_size = -6144"); // 6MiB + stmt.executeAsync(); + stmt.finalize(); + + // Create our in-memory tables for tab tracking. + initTempTable(db); + + // Populate the table with current open pages cache contents. + if (this._openPagesCache.length > 0) { + // Avoid getter re-entrance from the _registerOpenPageQuery lazy getter. + let stmt = this._registerOpenPageQuery = + db.createAsyncStatement(this._registerOpenPageQuerySQL); + let params = stmt.newBindingParamsArray(); + for (let i = 0; i < this._openPagesCache.length; i++) { + let bp = params.newBindingParams(); + bp.bindByName("page_url", this._openPagesCache[i]); + params.addParams(bp); + } + stmt.bindParameters(params); + stmt.executeAsync(); + stmt.finalize(); + delete this._openPagesCache; + } + + return db; + }); + + this._customQuery = (conditions = "") => { + return this._db.createAsyncStatement(baseQuery(conditions)); + }; + + XPCOMUtils.defineLazyGetter(this, "_defaultQuery", function() { + return this._db.createAsyncStatement(baseQuery()); + }); + + XPCOMUtils.defineLazyGetter(this, "_historyQuery", function() { + // Enforce ignoring the visit_count index, since the frecency one is much + // faster in this case. ANALYZE helps the query planner to figure out the + // faster path, but it may not have run yet. + return this._db.createAsyncStatement(baseQuery("AND +h.visit_count > 0")); + }); + + XPCOMUtils.defineLazyGetter(this, "_bookmarkQuery", function() { + return this._db.createAsyncStatement(baseQuery("AND bookmarked")); + }); + + XPCOMUtils.defineLazyGetter(this, "_tagsQuery", function() { + return this._db.createAsyncStatement(baseQuery("AND tags IS NOT NULL")); + }); + + XPCOMUtils.defineLazyGetter(this, "_openPagesQuery", function() { + return this._db.createAsyncStatement( + `SELECT t.url, t.url, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + :query_type, t.open_count, NULL + FROM moz_openpages_temp t + LEFT JOIN moz_places h ON h.url = t.url + WHERE h.id IS NULL + AND AUTOCOMPLETE_MATCH(:searchString, t.url, t.url, NULL, + NULL, NULL, NULL, t.open_count, + :matchBehavior, :searchBehavior) + ORDER BY t.ROWID DESC + LIMIT :maxResults` + ); + }); + + XPCOMUtils.defineLazyGetter(this, "_typedQuery", function() { + return this._db.createAsyncStatement(baseQuery("AND h.typed = 1")); + }); + + XPCOMUtils.defineLazyGetter(this, "_adaptiveQuery", function() { + return this._db.createAsyncStatement( + `/* do not warn (bug 487789) */ + SELECT h.url, h.title, f.url, ${kBookTagSQLFragment}, + h.visit_count, h.typed, h.id, :query_type, t.open_count + FROM ( + SELECT ROUND( + MAX(use_count) * (1 + (input = :search_string)), 1 + ) AS rank, place_id + FROM moz_inputhistory + WHERE input BETWEEN :search_string AND :search_string || X'FFFF' + GROUP BY place_id + ) AS i + JOIN moz_places h ON h.id = i.place_id + LEFT JOIN moz_favicons f ON f.id = h.favicon_id + LEFT JOIN moz_openpages_temp t ON t.url = h.url + WHERE AUTOCOMPLETE_MATCH(NULL, h.url, + IFNULL(btitle, h.title), tags, + h.visit_count, h.typed, bookmarked, + t.open_count, + :matchBehavior, :searchBehavior) + ORDER BY rank DESC, h.frecency DESC` + ); + }); + + XPCOMUtils.defineLazyGetter(this, "_keywordQuery", function() { + return this._db.createAsyncStatement( + `/* do not warn (bug 487787) */ + SELECT REPLACE(h.url, '%s', :query_string) AS search_url, h.title, + IFNULL(f.url, (SELECT f.url + FROM moz_places + JOIN moz_favicons f ON f.id = favicon_id + WHERE rev_host = h.rev_host + ORDER BY frecency DESC + LIMIT 1) + ), 1, NULL, NULL, h.visit_count, h.typed, h.id, + :query_type, t.open_count + FROM moz_keywords k + JOIN moz_places h ON k.place_id = h.id + LEFT JOIN moz_favicons f ON f.id = h.favicon_id + LEFT JOIN moz_openpages_temp t ON t.url = search_url + WHERE k.keyword = LOWER(:keyword)` + ); + }); + + this._registerOpenPageQuerySQL = + `INSERT OR REPLACE INTO moz_openpages_temp (url, open_count) + VALUES (:page_url, + IFNULL( + ( + SELECT open_count + 1 + FROM moz_openpages_temp + WHERE url = :page_url + ), + 1 + ) + )`; + XPCOMUtils.defineLazyGetter(this, "_registerOpenPageQuery", function() { + return this._db.createAsyncStatement(this._registerOpenPageQuerySQL); + }); + + XPCOMUtils.defineLazyGetter(this, "_unregisterOpenPageQuery", function() { + return this._db.createAsyncStatement( + `UPDATE moz_openpages_temp + SET open_count = open_count - 1 + WHERE url = :page_url` + ); + }); + + ////////////////////////////////////////////////////////////////////////////// + //// Initialization + + // load preferences + this._prefs = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefService). + getBranch(kBrowserUrlbarBranch); + this._syncEnabledPref(); + this._loadPrefs(true); + + // register observers + this._os = Cc["@mozilla.org/observer-service;1"]. + getService(Ci.nsIObserverService); + this._os.addObserver(this, kTopicShutdown, false); + +} + +nsPlacesAutoComplete.prototype = { + ////////////////////////////////////////////////////////////////////////////// + //// nsIAutoCompleteSearch + + startSearch: function PAC_startSearch(aSearchString, aSearchParam, + aPreviousResult, aListener) + { + // Stop the search in case the controller has not taken care of it. + this.stopSearch(); + + // Note: We don't use aPreviousResult to make sure ordering of results are + // consistent. See bug 412730 for more details. + + // We want to store the original string with no leading or trailing + // whitespace for case sensitive searches. + this._originalSearchString = aSearchString.trim(); + + this._currentSearchString = + fixupSearchText(this._originalSearchString.toLowerCase()); + + let params = new Set(aSearchParam.split(" ")); + this._enableActions = params.has("enable-actions"); + this._disablePrivateActions = params.has("disable-private-actions"); + + this._listener = aListener; + let result = Cc["@mozilla.org/autocomplete/simple-result;1"]. + createInstance(Ci.nsIAutoCompleteSimpleResult); + result.setSearchString(aSearchString); + result.setListener(this); + this._result = result; + + // If we are not enabled, we need to return now. + if (!this._enabled) { + this._finishSearch(true); + return; + } + + // Reset our search behavior to the default. + if (this._currentSearchString) { + this._behavior = this._defaultBehavior; + } + else { + this._behavior = this._emptySearchDefaultBehavior; + } + // For any given search, we run up to four queries: + // 1) keywords (this._keywordQuery) + // 2) adaptive learning (this._adaptiveQuery) + // 3) open pages not supported by history (this._openPagesQuery) + // 4) query from this._getSearch + // (1) only gets ran if we get any filtered tokens from this._getSearch, + // since if there are no tokens, there is nothing to match, so there is no + // reason to run the query). + let {query, tokens} = + this._getSearch(this._getUnfilteredSearchTokens(this._currentSearchString)); + let queries = tokens.length ? + [this._getBoundKeywordQuery(tokens), this._getBoundAdaptiveQuery()] : + [this._getBoundAdaptiveQuery()]; + + if (this._hasBehavior("openpage")) { + queries.push(this._getBoundOpenPagesQuery(tokens)); + } + queries.push(query); + + // Start executing our queries. + this._telemetryStartTime = Date.now(); + this._executeQueries(queries); + + // Set up our persistent state for the duration of the search. + this._searchTokens = tokens; + this._usedPlaces = {}; + }, + + stopSearch: function PAC_stopSearch() + { + // We need to cancel our searches so we do not get any [more] results. + // However, it's possible we haven't actually started any searches, so this + // method may throw because this._pendingQuery may be undefined. + if (this._pendingQuery) { + this._stopActiveQuery(); + } + + this._finishSearch(false); + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsIAutoCompleteSimpleResultListener + + onValueRemoved: function PAC_onValueRemoved(aResult, aURISpec, aRemoveFromDB) + { + if (aRemoveFromDB) { + PlacesUtils.history.removePage(NetUtil.newURI(aURISpec)); + } + }, + + ////////////////////////////////////////////////////////////////////////////// + //// mozIPlacesAutoComplete + + // If the connection has not yet been started, use this local cache. This + // prevents autocomplete from initing the database till the first search. + _openPagesCache: [], + registerOpenPage: function PAC_registerOpenPage(aURI) + { + if (!this._databaseInitialized) { + this._openPagesCache.push(aURI.spec); + return; + } + + let stmt = this._registerOpenPageQuery; + stmt.params.page_url = aURI.spec; + stmt.executeAsync(); + }, + + unregisterOpenPage: function PAC_unregisterOpenPage(aURI) + { + if (!this._databaseInitialized) { + let index = this._openPagesCache.indexOf(aURI.spec); + if (index != -1) { + this._openPagesCache.splice(index, 1); + } + return; + } + + let stmt = this._unregisterOpenPageQuery; + stmt.params.page_url = aURI.spec; + stmt.executeAsync(); + }, + + ////////////////////////////////////////////////////////////////////////////// + //// mozIStorageStatementCallback + + handleResult: function PAC_handleResult(aResultSet) + { + let row, haveMatches = false; + while ((row = aResultSet.getNextRow())) { + let match = this._processRow(row); + haveMatches = haveMatches || match; + + if (this._result.matchCount == this._maxRichResults) { + // We have enough results, so stop running our search. + this._stopActiveQuery(); + + // And finish our search. + this._finishSearch(true); + return; + } + + } + + // Notify about results if we've gotten them. + if (haveMatches) { + this._notifyResults(true); + } + }, + + handleError: function PAC_handleError(aError) + { + Components.utils.reportError("Places AutoComplete: An async statement encountered an " + + "error: " + aError.result + ", '" + aError.message + "'"); + }, + + handleCompletion: function PAC_handleCompletion(aReason) + { + // If we have already finished our search, we should bail out early. + if (this.isSearchComplete()) { + return; + } + + // If we do not have enough results, and our match type is + // MATCH_BOUNDARY_ANYWHERE, search again with MATCH_ANYWHERE to get more + // results. + if (this._matchBehavior == MATCH_BOUNDARY_ANYWHERE && + this._result.matchCount < this._maxRichResults && !this._secondPass) { + this._secondPass = true; + let queries = [ + this._getBoundAdaptiveQuery(MATCH_ANYWHERE), + this._getBoundSearchQuery(MATCH_ANYWHERE, this._searchTokens), + ]; + this._executeQueries(queries); + return; + } + + this._finishSearch(true); + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsIObserver + + observe: function PAC_observe(aSubject, aTopic, aData) + { + if (aTopic == kTopicShutdown) { + this._os.removeObserver(this, kTopicShutdown); + + // Remove our preference observer. + this._prefs.removeObserver("", this); + delete this._prefs; + + // Finalize the statements that we have used. + let stmts = [ + "_defaultQuery", + "_historyQuery", + "_bookmarkQuery", + "_tagsQuery", + "_openPagesQuery", + "_typedQuery", + "_adaptiveQuery", + "_keywordQuery", + "_registerOpenPageQuery", + "_unregisterOpenPageQuery", + ]; + for (let i = 0; i < stmts.length; i++) { + // We do not want to create any query we haven't already created, so + // see if it is a getter first. + if (Object.getOwnPropertyDescriptor(this, stmts[i]).value !== undefined) { + this[stmts[i]].finalize(); + } + } + + if (this._databaseInitialized) { + this._db.asyncClose(); + } + } + else if (aTopic == kPrefChanged) { + // Avoid re-entrancy when flipping linked preferences. + if (this._ignoreNotifications) + return; + this._ignoreNotifications = true; + this._loadPrefs(false, aTopic, aData); + this._ignoreNotifications = false; + } + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsPlacesAutoComplete + + get _databaseInitialized() { + return Object.getOwnPropertyDescriptor(this, "_db").value !== undefined; + }, + + /** + * Generates the tokens used in searching from a given string. + * + * @param aSearchString + * The string to generate tokens from. + * @return an array of tokens. + */ + _getUnfilteredSearchTokens: function PAC_unfilteredSearchTokens(aSearchString) + { + // Calling split on an empty string will return an array containing one + // empty string. We don't want that, as it'll break our logic, so return an + // empty array then. + return aSearchString.length ? aSearchString.split(" ") : []; + }, + + /** + * Properly cleans up when searching is completed. + * + * @param aNotify + * Indicates if we should notify the AutoComplete listener about our + * results or not. + */ + _finishSearch: function PAC_finishSearch(aNotify) + { + // Notify about results if we are supposed to. + if (aNotify) { + this._notifyResults(false); + } + + // Clear our state + delete this._originalSearchString; + delete this._currentSearchString; + delete this._strippedPrefix; + delete this._searchTokens; + delete this._listener; + delete this._result; + delete this._usedPlaces; + delete this._pendingQuery; + this._secondPass = false; + this._enableActions = false; + }, + + /** + * Executes the given queries asynchronously. + * + * @param aQueries + * The queries to execute. + */ + _executeQueries: function PAC_executeQueries(aQueries) + { + // Because we might get a handleCompletion for canceled queries, we want to + // filter out queries we no longer care about (described in the + // handleCompletion implementation of AutoCompleteStatementCallbackWrapper). + + // Create our wrapper object and execute the queries. + let wrapper = new AutoCompleteStatementCallbackWrapper(this, this, this._db); + this._pendingQuery = wrapper.executeAsync(aQueries); + }, + + /** + * Stops executing our active query. + */ + _stopActiveQuery: function PAC_stopActiveQuery() + { + this._pendingQuery.cancel(); + delete this._pendingQuery; + }, + + /** + * Notifies the listener about results. + * + * @param aSearchOngoing + * Indicates if the search is ongoing or not. + */ + _notifyResults: function PAC_notifyResults(aSearchOngoing) + { + let result = this._result; + let resultCode = result.matchCount ? "RESULT_SUCCESS" : "RESULT_NOMATCH"; + if (aSearchOngoing) { + resultCode += "_ONGOING"; + } + result.setSearchResult(Ci.nsIAutoCompleteResult[resultCode]); + this._listener.onSearchResult(this, result); + if (this._telemetryStartTime) { + let elapsed = Date.now() - this._telemetryStartTime; + if (elapsed > 50) { + try { + Services.telemetry + .getHistogramById("PLACES_AUTOCOMPLETE_1ST_RESULT_TIME_MS") + .add(elapsed); + } catch (ex) { + Components.utils.reportError("Unable to report telemetry."); + } + } + this._telemetryStartTime = null; + } + }, + + /** + * Synchronize suggest.* prefs with autocomplete.enabled. + */ + _syncEnabledPref: function PAC_syncEnabledPref() + { + let suggestPrefs = ["suggest.history", "suggest.bookmark", "suggest.openpage"]; + let types = ["History", "Bookmark", "Openpage"]; + + this._enabled = safePrefGetter(this._prefs, kBrowserUrlbarAutocompleteEnabledPref, + true); + this._suggestHistory = safePrefGetter(this._prefs, "suggest.history", true); + this._suggestBookmark = safePrefGetter(this._prefs, "suggest.bookmark", true); + this._suggestOpenpage = safePrefGetter(this._prefs, "suggest.openpage", true); + + if (this._enabled) { + // If the autocomplete preference is active, activate all suggest + // preferences only if all of them are false. + if (types.every(type => this["_suggest" + type] == false)) { + for (let type of suggestPrefs) { + this._prefs.setBoolPref(type, true); + } + } + } else { + // If the preference was deactivated, deactivate all suggest preferences. + for (let type of suggestPrefs) { + this._prefs.setBoolPref(type, false); + } + } + }, + + /** + * Loads the preferences that we care about. + * + * @param [optional] aRegisterObserver + * Indicates if the preference observer should be added or not. The + * default value is false. + * @param [optional] aTopic + * Observer's topic, if any. + * @param [optional] aSubject + * Observer's subject, if any. + */ + _loadPrefs: function PAC_loadPrefs(aRegisterObserver, aTopic, aData) + { + // Avoid race conditions with UnifiedComplete component. + if (aData && !isUnifiedCompleteInstantiated()) { + // Synchronize suggest.* prefs with autocomplete.enabled. + if (aData == kBrowserUrlbarAutocompleteEnabledPref) { + this._syncEnabledPref(); + } else if (aData.startsWith("suggest.")) { + let suggestPrefs = ["suggest.history", "suggest.bookmark", "suggest.openpage"]; + this._prefs.setBoolPref(kBrowserUrlbarAutocompleteEnabledPref, + suggestPrefs.some(pref => safePrefGetter(this._prefs, pref, true))); + } + } + + this._enabled = safePrefGetter(this._prefs, + kBrowserUrlbarAutocompleteEnabledPref, + true); + this._matchBehavior = safePrefGetter(this._prefs, + "matchBehavior", + MATCH_BOUNDARY_ANYWHERE); + this._filterJavaScript = safePrefGetter(this._prefs, "filter.javascript", true); + this._maxRichResults = safePrefGetter(this._prefs, "maxRichResults", 25); + this._restrictHistoryToken = safePrefGetter(this._prefs, + "restrict.history", "^"); + this._restrictBookmarkToken = safePrefGetter(this._prefs, + "restrict.bookmark", "*"); + this._restrictTypedToken = safePrefGetter(this._prefs, "restrict.typed", "~"); + this._restrictTagToken = safePrefGetter(this._prefs, "restrict.tag", "+"); + this._restrictOpenPageToken = safePrefGetter(this._prefs, + "restrict.openpage", "%"); + this._matchTitleToken = safePrefGetter(this._prefs, "match.title", "#"); + this._matchURLToken = safePrefGetter(this._prefs, "match.url", "@"); + + this._suggestHistory = safePrefGetter(this._prefs, "suggest.history", true); + this._suggestBookmark = safePrefGetter(this._prefs, "suggest.bookmark", true); + this._suggestOpenpage = safePrefGetter(this._prefs, "suggest.openpage", true); + this._suggestTyped = safePrefGetter(this._prefs, "suggest.history.onlyTyped", false); + + // If history is not set, onlyTyped value should be ignored. + if (!this._suggestHistory) { + this._suggestTyped = false; + } + let types = ["History", "Bookmark", "Openpage", "Typed"]; + this._defaultBehavior = types.reduce((memo, type) => { + let prefValue = this["_suggest" + type]; + return memo | (prefValue && + Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type.toUpperCase()]); + }, 0); + + // Further restrictions to apply for "empty searches" (i.e. searches for ""). + // The empty behavior is typed history, if history is enabled. Otherwise, + // it is bookmarks, if they are enabled. If both history and bookmarks are disabled, + // it defaults to open pages. + this._emptySearchDefaultBehavior = Ci.mozIPlacesAutoComplete.BEHAVIOR_RESTRICT; + if (this._suggestHistory) { + this._emptySearchDefaultBehavior |= Ci.mozIPlacesAutoComplete.BEHAVIOR_HISTORY | + Ci.mozIPlacesAutoComplete.BEHAVIOR_TYPED; + } else if (this._suggestBookmark) { + this._emptySearchDefaultBehavior |= Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK; + } else { + this._emptySearchDefaultBehavior |= Ci.mozIPlacesAutoComplete.BEHAVIOR_OPENPAGE; + } + + // Validate matchBehavior; default to MATCH_BOUNDARY_ANYWHERE. + if (this._matchBehavior != MATCH_ANYWHERE && + this._matchBehavior != MATCH_BOUNDARY && + this._matchBehavior != MATCH_BEGINNING) { + this._matchBehavior = MATCH_BOUNDARY_ANYWHERE; + } + // register observer + if (aRegisterObserver) { + this._prefs.addObserver("", this, false); + } + }, + + /** + * Given an array of tokens, this function determines which query should be + * ran. It also removes any special search tokens. + * + * @param aTokens + * An array of search tokens. + * @return an object with two properties: + * query: the correctly optimized, bound query to search the database + * with. + * tokens: the filtered list of tokens to search with. + */ + _getSearch: function PAC_getSearch(aTokens) + { + let foundToken = false; + let restrict = (behavior) => { + if (!foundToken) { + this._behavior = 0; + this._setBehavior("restrict"); + foundToken = true; + } + this._setBehavior(behavior); + }; + + // Set the proper behavior so our call to _getBoundSearchQuery gives us the + // correct query. + for (let i = aTokens.length - 1; i >= 0; i--) { + switch (aTokens[i]) { + case this._restrictHistoryToken: + restrict("history"); + break; + case this._restrictBookmarkToken: + restrict("bookmark"); + break; + case this._restrictTagToken: + restrict("tag"); + break; + case this._restrictOpenPageToken: + if (!this._enableActions) { + continue; + } + restrict("openpage"); + break; + case this._matchTitleToken: + restrict("title"); + break; + case this._matchURLToken: + restrict("url"); + break; + case this._restrictTypedToken: + restrict("typed"); + break; + default: + // We do not want to remove the token if we did not match. + continue; + } + + aTokens.splice(i, 1); + } + + // Set the right JavaScript behavior based on our preference. Note that the + // preference is whether or not we should filter JavaScript, and the + // behavior is if we should search it or not. + if (!this._filterJavaScript) { + this._setBehavior("javascript"); + } + + return { + query: this._getBoundSearchQuery(this._matchBehavior, aTokens), + tokens: aTokens + }; + }, + + /** + * @return a string consisting of the search query to be used based on the + * previously set urlbar suggestion preferences. + */ + _getSuggestionPrefQuery: function PAC_getSuggestionPrefQuery() + { + if (!this._hasBehavior("restrict") && this._hasBehavior("history") && + this._hasBehavior("bookmark")) { + return this._hasBehavior("typed") ? this._customQuery("AND h.typed = 1") + : this._defaultQuery; + } + let conditions = []; + if (this._hasBehavior("history")) { + // Enforce ignoring the visit_count index, since the frecency one is much + // faster in this case. ANALYZE helps the query planner to figure out the + // faster path, but it may not have up-to-date information yet. + conditions.push("+h.visit_count > 0"); + } + if (this._hasBehavior("typed")) { + conditions.push("h.typed = 1"); + } + if (this._hasBehavior("bookmark")) { + conditions.push("bookmarked"); + } + if (this._hasBehavior("tag")) { + conditions.push("tags NOTNULL"); + } + + return conditions.length ? this._customQuery("AND " + conditions.join(" AND ")) + : this._defaultQuery; + }, + + /** + * Obtains the search query to be used based on the previously set search + * behaviors (accessed by this._hasBehavior). The query is bound and ready to + * execute. + * + * @param aMatchBehavior + * How this query should match its tokens to the search string. + * @param aTokens + * An array of search tokens. + * @return the correctly optimized query to search the database with and the + * new list of tokens to search with. The query has all the needed + * parameters bound, so consumers can execute it without doing any + * additional work. + */ + _getBoundSearchQuery: function PAC_getBoundSearchQuery(aMatchBehavior, + aTokens) + { + let query = this._getSuggestionPrefQuery(); + + // Bind the needed parameters to the query so consumers can use it. + let params = query.params; + params.parent = PlacesUtils.tagsFolderId; + params.query_type = kQueryTypeFiltered; + params.matchBehavior = aMatchBehavior; + params.searchBehavior = this._behavior; + + // We only want to search the tokens that we are left with - not the + // original search string. + params.searchString = aTokens.join(" "); + + // Limit the query to the the maximum number of desired results. + // This way we can avoid doing more work than needed. + params.maxResults = this._maxRichResults; + + return query; + }, + + _getBoundOpenPagesQuery: function PAC_getBoundOpenPagesQuery(aTokens) + { + let query = this._openPagesQuery; + + // Bind the needed parameters to the query so consumers can use it. + let params = query.params; + params.query_type = kQueryTypeFiltered; + params.matchBehavior = this._matchBehavior; + params.searchBehavior = this._behavior; + + // We only want to search the tokens that we are left with - not the + // original search string. + params.searchString = aTokens.join(" "); + params.maxResults = this._maxRichResults; + + return query; + }, + + /** + * Obtains the keyword query with the properly bound parameters. + * + * @param aTokens + * The array of search tokens to check against. + * @return the bound keyword query. + */ + _getBoundKeywordQuery: function PAC_getBoundKeywordQuery(aTokens) + { + // The keyword is the first word in the search string, with the parameters + // following it. + let searchString = this._originalSearchString; + let queryString = ""; + let queryIndex = searchString.indexOf(" "); + if (queryIndex != -1) { + queryString = searchString.substring(queryIndex + 1); + } + // We need to escape the parameters as if they were the query in a URL + queryString = encodeURIComponent(queryString).replace(/%20/g, "+"); + + // The first word could be a keyword, so that's what we'll search. + let keyword = aTokens[0]; + + let query = this._keywordQuery; + let params = query.params; + params.keyword = keyword; + params.query_string = queryString; + params.query_type = kQueryTypeKeyword; + + return query; + }, + + /** + * Obtains the adaptive query with the properly bound parameters. + * + * @return the bound adaptive query. + */ + _getBoundAdaptiveQuery: function PAC_getBoundAdaptiveQuery(aMatchBehavior) + { + // If we were not given a match behavior, use the stored match behavior. + if (arguments.length == 0) { + aMatchBehavior = this._matchBehavior; + } + + let query = this._adaptiveQuery; + let params = query.params; + params.parent = PlacesUtils.tagsFolderId; + params.search_string = this._currentSearchString; + params.query_type = kQueryTypeFiltered; + params.matchBehavior = aMatchBehavior; + params.searchBehavior = this._behavior; + + return query; + }, + + /** + * Processes a mozIStorageRow to generate the proper data for the AutoComplete + * result. This will add an entry to the current result if it matches the + * criteria. + * + * @param aRow + * The row to process. + * @return true if the row is accepted, and false if not. + */ + _processRow: function PAC_processRow(aRow) + { + // Before we do any work, make sure this entry isn't already in our results. + let entryId = aRow.getResultByIndex(kQueryIndexPlaceId); + let escapedEntryURL = aRow.getResultByIndex(kQueryIndexURL); + let openPageCount = aRow.getResultByIndex(kQueryIndexOpenPageCount) || 0; + + // If actions are enabled and the page is open, add only the switch-to-tab + // result. Otherwise, add the normal result. + let [url, action] = this._enableActions && openPageCount > 0 && this._hasBehavior("openpage") ? + ["moz-action:switchtab," + escapedEntryURL, "action "] : + [escapedEntryURL, ""]; + + if (this._inResults(entryId, url)) { + return false; + } + + let entryTitle = aRow.getResultByIndex(kQueryIndexTitle) || ""; + let entryFavicon = aRow.getResultByIndex(kQueryIndexFaviconURL) || ""; + let entryBookmarked = aRow.getResultByIndex(kQueryIndexBookmarked); + let entryBookmarkTitle = entryBookmarked ? + aRow.getResultByIndex(kQueryIndexBookmarkTitle) : null; + let entryTags = aRow.getResultByIndex(kQueryIndexTags) || ""; + + // Always prefer the bookmark title unless it is empty + let title = entryBookmarkTitle || entryTitle; + + let style; + if (aRow.getResultByIndex(kQueryIndexQueryType) == kQueryTypeKeyword) { + style = "keyword"; + title = NetUtil.newURI(escapedEntryURL).host; + } + + // We will always prefer to show tags if we have them. + let showTags = !!entryTags; + + // However, we'll act as if a page is not bookmarked if the user wants + // only history and not bookmarks and there are no tags. + if (this._hasBehavior("history") && !this._hasBehavior("bookmark") && + !showTags) { + showTags = false; + style = "favicon"; + } + + // If we have tags and should show them, we need to add them to the title. + if (showTags) { + title += kTitleTagsSeparator + entryTags; + } + // We have to determine the right style to display. Tags show the tag icon, + // bookmarks get the bookmark icon, and keywords get the keyword icon. If + // the result does not fall into any of those, it just gets the favicon. + if (!style) { + // It is possible that we already have a style set (from a keyword + // search or because of the user's preferences), so only set it if we + // haven't already done so. + if (showTags) { + style = "tag"; + } + else if (entryBookmarked) { + style = "bookmark"; + } + else { + style = "favicon"; + } + } + + this._addToResults(entryId, url, title, entryFavicon, action + style); + return true; + }, + + /** + * Checks to see if the given place has already been added to the results. + * + * @param aPlaceId + * The place id to check for, may be null. + * @param aUrl + * The url to check for. + * @return true if the place has been added, false otherwise. + * + * @note Must check both the id and the url for a negative match, since + * autocomplete may run in the middle of a new page addition. In such + * a case the switch-to-tab query would hash the page by url, then a + * next query, running after the page addition, would hash it by id. + * It's not possible to just rely on url though, since keywords + * dynamically modify the url to include their search string. + */ + _inResults: function PAC_inResults(aPlaceId, aUrl) + { + if (aPlaceId && aPlaceId in this._usedPlaces) { + return true; + } + return aUrl in this._usedPlaces; + }, + + /** + * Adds a result to the AutoComplete results. Also tracks that we've added + * this place_id into the result set. + * + * @param aPlaceId + * The place_id of the item to be added to the result set. This is + * used by _inResults. + * @param aURISpec + * The URI spec for the entry. + * @param aTitle + * The title to give the entry. + * @param aFaviconSpec + * The favicon to give to the entry. + * @param aStyle + * Indicates how the entry should be styled when displayed. + */ + _addToResults: function PAC_addToResults(aPlaceId, aURISpec, aTitle, + aFaviconSpec, aStyle) + { + // Add this to our internal tracker to ensure duplicates do not end up in + // the result. _usedPlaces is an Object that is being used as a set. + // Not all entries have a place id, thus we fallback to the url for them. + // We cannot use only the url since keywords entries are modified to + // include the search string, and would be returned multiple times. Ids + // are faster too. + this._usedPlaces[aPlaceId || aURISpec] = true; + + // Obtain the favicon for this URI. + let favicon; + if (aFaviconSpec) { + let uri = NetUtil.newURI(aFaviconSpec); + favicon = PlacesUtils.favicons.getFaviconLinkForIcon(uri).spec; + } + favicon = favicon || PlacesUtils.favicons.defaultFavicon.spec; + + this._result.appendMatch(aURISpec, aTitle, favicon, aStyle); + }, + + /** + * Determines if the specified AutoComplete behavior is set. + * + * @param aType + * The behavior type to test for. + * @return true if the behavior is set, false otherwise. + */ + _hasBehavior: function PAC_hasBehavior(aType) + { + let behavior = Ci.mozIPlacesAutoComplete["BEHAVIOR_" + aType.toUpperCase()]; + + if (this._disablePrivateActions && + behavior == Ci.mozIPlacesAutoComplete.BEHAVIOR_OPENPAGE) { + return false; + } + + return this._behavior & behavior; + }, + + /** + * Enables the desired AutoComplete behavior. + * + * @param aType + * The behavior type to set. + */ + _setBehavior: function PAC_setBehavior(aType) + { + this._behavior |= + Ci.mozIPlacesAutoComplete["BEHAVIOR_" + aType.toUpperCase()]; + }, + + /** + * Determines if we are done searching or not. + * + * @return true if we have completed searching, false otherwise. + */ + isSearchComplete: function PAC_isSearchComplete() + { + // If _pendingQuery is null, we should no longer do any work since we have + // already called _finishSearch. This means we completed our search. + return this._pendingQuery == null; + }, + + /** + * Determines if the given handle of a pending statement is a pending search + * or not. + * + * @param aHandle + * A mozIStoragePendingStatement to check and see if we are waiting for + * results from it still. + * @return true if it is a pending query, false otherwise. + */ + isPendingSearch: function PAC_isPendingSearch(aHandle) + { + return this._pendingQuery == aHandle; + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsISupports + + classID: Components.ID("d0272978-beab-4adc-a3d4-04b76acfa4e7"), + + _xpcom_factory: XPCOMUtils.generateSingletonFactory(nsPlacesAutoComplete), + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIAutoCompleteSearch, + Ci.nsIAutoCompleteSimpleResultListener, + Ci.mozIPlacesAutoComplete, + Ci.mozIStorageStatementCallback, + Ci.nsIObserver, + Ci.nsISupportsWeakReference, + ]) +}; + +//////////////////////////////////////////////////////////////////////////////// +//// urlInlineComplete class +//// component @mozilla.org/autocomplete/search;1?name=urlinline + +function urlInlineComplete() +{ + this._loadPrefs(true); + Services.obs.addObserver(this, kTopicShutdown, true); +} + +urlInlineComplete.prototype = { + +///////////////////////////////////////////////////////////////////////////////// +//// Database and query getters + + __db: null, + + get _db() + { + if (!this.__db && this._autofillEnabled) { + this.__db = PlacesUtils.history.DBConnection.clone(true); + } + return this.__db; + }, + + __hostQuery: null, + + get _hostQuery() + { + if (!this.__hostQuery) { + // Add a trailing slash at the end of the hostname, since we always + // want to complete up to and including a URL separator. + this.__hostQuery = this._db.createAsyncStatement( + `/* do not warn (bug no): could index on (typed,frecency) but not worth it */ + SELECT host || '/', prefix || host || '/' + FROM moz_hosts + WHERE host BETWEEN :search_string AND :search_string || X'FFFF' + AND frecency <> 0 + ${this._autofillTyped ? "AND typed = 1" : ""} + ORDER BY frecency DESC + LIMIT 1` + ); + } + return this.__hostQuery; + }, + + __urlQuery: null, + + get _urlQuery() + { + if (!this.__urlQuery) { + this.__urlQuery = this._db.createAsyncStatement( + `/* do not warn (bug no): can't use an index */ + SELECT h.url + FROM moz_places h + WHERE h.frecency <> 0 + ${this._autofillTyped ? "AND h.typed = 1 " : ""} + AND AUTOCOMPLETE_MATCH(:searchString, h.url, + h.title, '', + h.visit_count, h.typed, 0, 0, + :matchBehavior, :searchBehavior) + ORDER BY h.frecency DESC, h.id DESC + LIMIT 1` + ); + } + return this.__urlQuery; + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsIAutoCompleteSearch + + startSearch: function UIC_startSearch(aSearchString, aSearchParam, + aPreviousResult, aListener) + { + // Stop the search in case the controller has not taken care of it. + if (this._pendingQuery) { + this.stopSearch(); + } + + let pendingSearch = this._pendingSearch = {}; + + // We want to store the original string with no leading or trailing + // whitespace for case sensitive searches. + this._originalSearchString = aSearchString; + this._currentSearchString = + fixupSearchText(this._originalSearchString.toLowerCase()); + // The protocol and the host are lowercased by nsIURI, so it's fine to + // lowercase the typed prefix to add it back to the results later. + this._strippedPrefix = this._originalSearchString.slice( + 0, this._originalSearchString.length - this._currentSearchString.length + ).toLowerCase(); + + this._result = Cc["@mozilla.org/autocomplete/simple-result;1"]. + createInstance(Ci.nsIAutoCompleteSimpleResult); + this._result.setSearchString(aSearchString); + this._result.setTypeAheadResult(true); + + this._listener = aListener; + + Task.spawn(function* () { + // Don't autoFill if the search term is recognized as a keyword, otherwise + // it will override default keywords behavior. Note that keywords are + // hashed on first use, so while the first query may delay a little bit, + // next ones will just hit the memory hash. + let dontAutoFill = this._currentSearchString.length == 0 || !this._db || + (yield PlacesUtils.keywords.fetch(this._currentSearchString)); + if (this._pendingSearch != pendingSearch) + return; + if (dontAutoFill) { + this._finishSearch(); + return; + } + + // Don't try to autofill if the search term includes any whitespace. + // This may confuse completeDefaultIndex cause the AUTOCOMPLETE_MATCH + // tokenizer ends up trimming the search string and returning a value + // that doesn't match it, or is even shorter. + if (/\s/.test(this._currentSearchString)) { + this._finishSearch(); + return; + } + + // Hosts have no "/" in them. + let lastSlashIndex = this._currentSearchString.lastIndexOf("/"); + + // Search only URLs if there's a slash in the search string... + if (lastSlashIndex != -1) { + // ...but not if it's exactly at the end of the search string. + if (lastSlashIndex < this._currentSearchString.length - 1) + this._queryURL(); + else + this._finishSearch(); + return; + } + + // Do a synchronous search on the table of hosts. + let query = this._hostQuery; + query.params.search_string = this._currentSearchString.toLowerCase(); + // This is just to measure the delay to reach the UI, not the query time. + TelemetryStopwatch.start(DOMAIN_QUERY_TELEMETRY); + let wrapper = new AutoCompleteStatementCallbackWrapper(this, { + handleResult: aResultSet => { + if (this._pendingSearch != pendingSearch) + return; + let row = aResultSet.getNextRow(); + let trimmedHost = row.getResultByIndex(0); + let untrimmedHost = row.getResultByIndex(1); + // If the untrimmed value doesn't preserve the user's input just + // ignore it and complete to the found host. + if (untrimmedHost && + !untrimmedHost.toLowerCase().includes(this._originalSearchString.toLowerCase())) { + untrimmedHost = null; + } + + this._result.appendMatch(this._strippedPrefix + trimmedHost, "", "", "", untrimmedHost); + + // handleCompletion() will cause the result listener to be called, and + // will display the result in the UI. + }, + + handleError: aError => { + Components.utils.reportError( + "URL Inline Complete: An async statement encountered an " + + "error: " + aError.result + ", '" + aError.message + "'"); + }, + + handleCompletion: aReason => { + if (this._pendingSearch != pendingSearch) + return; + TelemetryStopwatch.finish(DOMAIN_QUERY_TELEMETRY); + this._finishSearch(); + } + }, this._db); + this._pendingQuery = wrapper.executeAsync([query]); + }.bind(this)); + }, + + /** + * Execute an asynchronous search through places, and complete + * up to the next URL separator. + */ + _queryURL: function UIC__queryURL() + { + // The URIs in the database are fixed up, so we can match on a lowercased + // host, but the path must be matched in a case sensitive way. + let pathIndex = + this._originalSearchString.indexOf("/", this._strippedPrefix.length); + this._currentSearchString = fixupSearchText( + this._originalSearchString.slice(0, pathIndex).toLowerCase() + + this._originalSearchString.slice(pathIndex) + ); + + // Within the standard autocomplete query, we only search the beginning + // of URLs for 1 result. + let query = this._urlQuery; + let params = query.params; + params.matchBehavior = MATCH_BEGINNING_CASE_SENSITIVE; + params.searchBehavior |= Ci.mozIPlacesAutoComplete.BEHAVIOR_HISTORY | + Ci.mozIPlacesAutoComplete.BEHAVIOR_TYPED | + Ci.mozIPlacesAutoComplete.BEHAVIOR_URL; + params.searchString = this._currentSearchString; + + // Execute the query. + let wrapper = new AutoCompleteStatementCallbackWrapper(this, { + handleResult: aResultSet => { + let row = aResultSet.getNextRow(); + let value = row.getResultByIndex(0); + let url = fixupSearchText(value); + + let prefix = value.slice(0, value.length - stripPrefix(value).length); + + // We must complete the URL up to the next separator (which is /, ? or #). + let separatorIndex = url.slice(this._currentSearchString.length) + .search(/[\/\?\#]/); + if (separatorIndex != -1) { + separatorIndex += this._currentSearchString.length; + if (url[separatorIndex] == "/") { + separatorIndex++; // Include the "/" separator + } + url = url.slice(0, separatorIndex); + } + + // Add the result. + // If the untrimmed value doesn't preserve the user's input just + // ignore it and complete to the found url. + let untrimmedURL = prefix + url; + if (untrimmedURL && + !untrimmedURL.toLowerCase().includes(this._originalSearchString.toLowerCase())) { + untrimmedURL = null; + } + + this._result.appendMatch(this._strippedPrefix + url, "", "", "", untrimmedURL); + + // handleCompletion() will cause the result listener to be called, and + // will display the result in the UI. + }, + + handleError: aError => { + Components.utils.reportError( + "URL Inline Complete: An async statement encountered an " + + "error: " + aError.result + ", '" + aError.message + "'"); + }, + + handleCompletion: aReason => { + this._finishSearch(); + } + }, this._db); + this._pendingQuery = wrapper.executeAsync([query]); + }, + + stopSearch: function UIC_stopSearch() + { + delete this._originalSearchString; + delete this._currentSearchString; + delete this._result; + delete this._listener; + delete this._pendingSearch; + + if (this._pendingQuery) { + this._pendingQuery.cancel(); + delete this._pendingQuery; + } + }, + + /** + * Loads the preferences that we care about. + * + * @param [optional] aRegisterObserver + * Indicates if the preference observer should be added or not. The + * default value is false. + */ + _loadPrefs: function UIC_loadPrefs(aRegisterObserver) + { + let prefBranch = Services.prefs.getBranch(kBrowserUrlbarBranch); + let autocomplete = safePrefGetter(prefBranch, + kBrowserUrlbarAutocompleteEnabledPref, + true); + let autofill = safePrefGetter(prefBranch, + kBrowserUrlbarAutofillPref, + true); + this._autofillEnabled = autocomplete && autofill; + this._autofillTyped = safePrefGetter(prefBranch, + kBrowserUrlbarAutofillTypedPref, + true); + if (aRegisterObserver) { + Services.prefs.addObserver(kBrowserUrlbarBranch, this, true); + } + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsIAutoCompleteSearchDescriptor + + get searchType() { + return Ci.nsIAutoCompleteSearchDescriptor.SEARCH_TYPE_IMMEDIATE; + }, + + get clearingAutoFillSearchesAgain() { + return false; + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsIObserver + + observe: function UIC_observe(aSubject, aTopic, aData) + { + if (aTopic == kTopicShutdown) { + this._closeDatabase(); + } + else if (aTopic == kPrefChanged && + (aData.substr(kBrowserUrlbarBranch.length) == kBrowserUrlbarAutofillPref || + aData.substr(kBrowserUrlbarBranch.length) == kBrowserUrlbarAutocompleteEnabledPref || + aData.substr(kBrowserUrlbarBranch.length) == kBrowserUrlbarAutofillTypedPref)) { + let previousAutofillTyped = this._autofillTyped; + this._loadPrefs(); + if (!this._autofillEnabled) { + this.stopSearch(); + this._closeDatabase(); + } + else if (this._autofillTyped != previousAutofillTyped) { + // Invalidate the statements to update them for the new typed status. + this._invalidateStatements(); + } + } + }, + + /** + * Finalizes and invalidates cached statements. + */ + _invalidateStatements: function UIC_invalidateStatements() + { + // Finalize the statements that we have used. + let stmts = [ + "__hostQuery", + "__urlQuery", + ]; + for (let i = 0; i < stmts.length; i++) { + // We do not want to create any query we haven't already created, so + // see if it is a getter first. + if (this[stmts[i]]) { + this[stmts[i]].finalize(); + this[stmts[i]] = null; + } + } + }, + + /** + * Closes the database. + */ + _closeDatabase: function UIC_closeDatabase() + { + this._invalidateStatements(); + if (this.__db) { + this._db.asyncClose(); + this.__db = null; + } + }, + + ////////////////////////////////////////////////////////////////////////////// + //// urlInlineComplete + + _finishSearch: function UIC_finishSearch() + { + // Notify the result object + let result = this._result; + + if (result.matchCount) { + result.setDefaultIndex(0); + result.setSearchResult(Ci.nsIAutoCompleteResult["RESULT_SUCCESS"]); + } else { + result.setDefaultIndex(-1); + result.setSearchResult(Ci.nsIAutoCompleteResult["RESULT_NOMATCH"]); + } + + this._listener.onSearchResult(this, result); + this.stopSearch(); + }, + + isSearchComplete: function UIC_isSearchComplete() + { + return this._pendingQuery == null; + }, + + isPendingSearch: function UIC_isPendingSearch(aHandle) + { + return this._pendingQuery == aHandle; + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsISupports + + classID: Components.ID("c88fae2d-25cf-4338-a1f4-64a320ea7440"), + + _xpcom_factory: XPCOMUtils.generateSingletonFactory(urlInlineComplete), + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIAutoCompleteSearch, + Ci.nsIAutoCompleteSearchDescriptor, + Ci.nsIObserver, + Ci.nsISupportsWeakReference, + ]) +}; + +var components = [nsPlacesAutoComplete, urlInlineComplete]; +this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components); diff --git a/toolkit/components/places/nsPlacesAutoComplete.manifest b/toolkit/components/places/nsPlacesAutoComplete.manifest new file mode 100644 index 000000000..eb704f449 --- /dev/null +++ b/toolkit/components/places/nsPlacesAutoComplete.manifest @@ -0,0 +1,6 @@ +component {d0272978-beab-4adc-a3d4-04b76acfa4e7} nsPlacesAutoComplete.js +contract @mozilla.org/autocomplete/search;1?name=history {d0272978-beab-4adc-a3d4-04b76acfa4e7} + +component {c88fae2d-25cf-4338-a1f4-64a320ea7440} nsPlacesAutoComplete.js +contract @mozilla.org/autocomplete/search;1?name=urlinline {c88fae2d-25cf-4338-a1f4-64a320ea7440} + diff --git a/toolkit/components/places/nsTaggingService.js b/toolkit/components/places/nsTaggingService.js index 1fad67a82..e367e6cb3 100644 --- a/toolkit/components/places/nsTaggingService.js +++ b/toolkit/components/places/nsTaggingService.js @@ -528,6 +528,10 @@ TagAutoCompleteResult.prototype = { return this._results.length; }, + get typeAheadResult() { + return false; + }, + /** * Get the value of the result at the given index */ diff --git a/toolkit/components/satchel/test/test_form_autocomplete.html b/toolkit/components/satchel/test/test_form_autocomplete.html index 4cf09117a..d2c22a3db 100644 --- a/toolkit/components/satchel/test/test_form_autocomplete.html +++ b/toolkit/components/satchel/test/test_form_autocomplete.html @@ -172,7 +172,7 @@ function setupFormHistory(aCallback) { { op : "add", fieldname : "field8", value : "value" }, { op : "add", fieldname : "field9", value : "value" }, { op : "add", fieldname : "field10", value : "42" }, - { op : "add", fieldname : "field11", value : "2010-10-10" }, + { op : "add", fieldname : "field11", value : "2010-10-10" }, // not used, since type=date doesn't have autocomplete currently { op : "add", fieldname : "field12", value : "21:21" }, // not used, since type=time doesn't have autocomplete currently { op : "add", fieldname : "field13", value : "32" }, // not used, since type=range doesn't have a drop down menu { op : "add", fieldname : "field14", value : "#ffffff" }, // not used, since type=color doesn't have autocomplete currently @@ -899,15 +899,13 @@ function runTest() { input = $_(14, "field11"); restoreForm(); - expectPopup(); - doKey("down"); + waitForMenuChange(0); break; case 405: - checkMenuEntries(["2010-10-10"]); - doKey("down"); - doKey("return"); - checkForm("2010-10-10"); + checkMenuEntries([]); // type=date with it's own control frame does not + // have a drop down menu for now + checkForm(""); input = $_(15, "field12"); restoreForm(); diff --git a/toolkit/content/browser-content.js b/toolkit/content/browser-content.js index 4ae798fbd..1376f70a3 100644 --- a/toolkit/content/browser-content.js +++ b/toolkit/content/browser-content.js @@ -1714,6 +1714,14 @@ let DateTimePickerListener = { (aEvent.originalTarget.type == "time" && !this.getTimePickerPref())) { return; } + + if (this._inputElement) { + // This happens when we're trying to open a picker when another picker + // is still open. We ignore this request to let the first picker + // close gracefully. + return; + } + this._inputElement = aEvent.originalTarget; this._inputElement.setDateTimePickerState(true); this.addListeners(); @@ -1728,15 +1736,17 @@ let DateTimePickerListener = { // element's value. value: Object.keys(value).length > 0 ? value : this._inputElement.value, - step: this._inputElement.step, - min: this._inputElement.min, - max: this._inputElement.max, + min: this._inputElement.getMinimum(), + max: this._inputElement.getMaximum(), + step: this._inputElement.getStep(), + stepBase: this._inputElement.getStepBase(), }, }); break; } case "MozUpdateDateTimePicker": { let value = this._inputElement.getDateTimeInputBoxValue(); + value.type = this._inputElement.type; sendAsyncMessage("FormDateTime:UpdatePicker", { value }); break; } diff --git a/toolkit/content/customizeToolbar.js b/toolkit/content/customizeToolbar.js index b96b60b98..7400aaadc 100644 --- a/toolkit/content/customizeToolbar.js +++ b/toolkit/content/customizeToolbar.js @@ -2,6 +2,8 @@ * 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/. */ +const gToolbarInfoSeparators = ["|", "-"]; + var gToolboxDocument = null; var gToolbox = null; var gCurrentDragOverItem = null; @@ -173,9 +175,20 @@ function persistCurrentSets() // Remove custom toolbars whose contents have been removed. gToolbox.removeChild(toolbar); } else if (gToolbox.toolbarset) { + var hidingAttribute = toolbar.getAttribute("type") == "menubar" ? + "autohide" : "collapsed"; // Persist custom toolbar info on the <toolbarset/> - gToolbox.toolbarset.setAttribute("toolbar"+(++customCount), - toolbar.toolbarName + ":" + currentSet); + // Attributes: + // Names: "toolbarX" (X - the number of the toolbar) + // Values: "Name|HidingAttributeName-HidingAttributeValue|CurrentSet" + gToolbox.toolbarset.setAttribute("toolbar" + (++customCount), + toolbar.toolbarName + + gToolbarInfoSeparators[0] + + hidingAttribute + + gToolbarInfoSeparators[1] + + toolbar.getAttribute(hidingAttribute) + + gToolbarInfoSeparators[0] + + currentSet); gToolboxDocument.persist(gToolbox.toolbarset.id, "toolbar"+customCount); } } @@ -485,6 +498,11 @@ function addNewToolbar() continue; } + if (name.value.includes(gToolbarInfoSeparators[0])) { + message = stringBundle.getFormattedString("enterToolbarIllegalChars", [name.value]); + continue; + } + var dupeFound = false; // Check for an existing toolbar with the same display name @@ -506,7 +524,7 @@ function addNewToolbar() message = stringBundle.getFormattedString("enterToolbarDup", [name.value]); } - gToolbox.appendCustomToolbar(name.value, ""); + gToolbox.appendCustomToolbar(name.value, "", [null, null]); toolboxChanged(); diff --git a/toolkit/content/datepicker.xhtml b/toolkit/content/datepicker.xhtml new file mode 100644 index 000000000..4da6e398f --- /dev/null +++ b/toolkit/content/datepicker.xhtml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE html [ + <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "DTD/xhtml1-strict.dtd"> + %htmlDTD; +]> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<head> + <title>Date Picker</title> + <link rel="stylesheet" href="chrome://global/skin/datetimeinputpickers.css"/> + <script type="application/javascript" src="chrome://global/content/bindings/datekeeper.js"></script> + <script type="application/javascript" src="chrome://global/content/bindings/spinner.js"></script> + <script type="application/javascript" src="chrome://global/content/bindings/calendar.js"></script> + <script type="application/javascript" src="chrome://global/content/bindings/datepicker.js"></script> +</head> +<body> + <div id="date-picker"> + <div class="calendar-container"> + <div class="nav"> + <button class="left"/> + <button class="right"/> + </div> + <div class="week-header"></div> + <div class="days-viewport"> + <div class="days-view"></div> + </div> + </div> + <div class="month-year-container"> + <button class="month-year"/> + </div> + <div class="month-year-view"></div> + </div> + <template id="spinner-template"> + <div class="spinner-container"> + <button class="up"/> + <div class="spinner"></div> + <button class="down"/> + </div> + </template> + <script type="application/javascript"> + // We need to hide the scroll bar but maintain its scrolling + // capability, so using |overflow: hidden| is not an option. + // Instead, we are inserting a user agent stylesheet that is + // capable of selecting scrollbars, and do |display: none|. + var domWinUtls = window.QueryInterface(Components.interfaces.nsIInterfaceRequestor). + getInterface(Components.interfaces.nsIDOMWindowUtils); + domWinUtls.loadSheetUsingURIString('data:text/css,@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); scrollbar { display: none; }', domWinUtls.AGENT_SHEET); + // Create a DatePicker instance and prepare to be + // initialized by the "DatePickerInit" event from datetimepopup.xml + const root = document.getElementById("date-picker"); + new DatePicker({ + monthYear: root.querySelector(".month-year"), + monthYearView: root.querySelector(".month-year-view"), + buttonLeft: root.querySelector(".left"), + buttonRight: root.querySelector(".right"), + weekHeader: root.querySelector(".week-header"), + daysView: root.querySelector(".days-view") + }); + </script> +</body> +</html>
\ No newline at end of file diff --git a/toolkit/content/jar.mn b/toolkit/content/jar.mn index 851c72250..f0d4a62a4 100644 --- a/toolkit/content/jar.mn +++ b/toolkit/content/jar.mn @@ -45,6 +45,7 @@ toolkit.jar: content/global/customizeToolbar.js content/global/customizeToolbar.xul #endif + content/global/datepicker.xhtml #ifndef MOZ_FENNEC content/global/editMenuOverlay.js * content/global/editMenuOverlay.xul @@ -80,8 +81,11 @@ toolkit.jar: content/global/bindings/autocomplete.xml (widgets/autocomplete.xml) content/global/bindings/browser.xml (widgets/browser.xml) content/global/bindings/button.xml (widgets/button.xml) + content/global/bindings/calendar.js (widgets/calendar.js) content/global/bindings/checkbox.xml (widgets/checkbox.xml) content/global/bindings/colorpicker.xml (widgets/colorpicker.xml) + content/global/bindings/datekeeper.js (widgets/datekeeper.js) + content/global/bindings/datepicker.js (widgets/datepicker.js) content/global/bindings/datetimepicker.xml (widgets/datetimepicker.xml) content/global/bindings/datetimepopup.xml (widgets/datetimepopup.xml) content/global/bindings/datetimebox.xml (widgets/datetimebox.xml) diff --git a/toolkit/content/tests/browser/browser.ini b/toolkit/content/tests/browser/browser.ini index 278b2ffe0..67ba2f850 100644 --- a/toolkit/content/tests/browser/browser.ini +++ b/toolkit/content/tests/browser/browser.ini @@ -26,6 +26,7 @@ skip-if = !e10s [browser_contentTitle.js] [browser_crash_previous_frameloader.js] run-if = e10s && crashreporter +[browser_datetime_datepicker.js] [browser_default_image_filename.js] [browser_f7_caret_browsing.js] [browser_findbar.js] diff --git a/toolkit/content/tests/browser/browser_datetime_datepicker.js b/toolkit/content/tests/browser/browser_datetime_datepicker.js new file mode 100644 index 000000000..966a74e7a --- /dev/null +++ b/toolkit/content/tests/browser/browser_datetime_datepicker.js @@ -0,0 +1,284 @@ +/* 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/. */ + +"use strict"; + +const MONTH_YEAR = ".month-year", + DAYS_VIEW = ".days-view", + BTN_PREV_MONTH = ".prev", + BTN_NEXT_MONTH = ".next"; +const DATE_FORMAT = new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", timeZone: "UTC" }).format; + +// Create a list of abbreviations for calendar class names +const W = "weekend", + O = "outside", + S = "selection", + R = "out-of-range", + T = "today", + P = "off-step"; + +// Calendar classlist for 2016-12. Used to verify the classNames are correct. +const calendarClasslist_201612 = [ + [W, O], [O], [O], [O], [], [], [W], + [W], [], [], [], [], [], [W], + [W], [], [], [], [S], [], [W], + [W], [], [], [], [], [], [W], + [W], [], [], [], [], [], [W], + [W, O], [O], [O], [O], [O], [O], [W, O], +]; + +function getCalendarText() { + return helper.getChildren(DAYS_VIEW).map(child => child.textContent); +} + +function getCalendarClassList() { + return helper.getChildren(DAYS_VIEW).map(child => Array.from(child.classList)); +} + +function mergeArrays(a, b) { + return a.map((classlist, index) => classlist.concat(b[index])); +} + +let helper = new DateTimeTestHelper(); + +registerCleanupFunction(() => { + helper.cleanup(); +}); + +/** + * Test that date picker opens to today's date when input field is blank + */ +add_task(async function test_datepicker_today() { + const date = new Date(); + + await helper.openPicker("data:text/html, <input type='date'>"); + + Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(date)); + + await helper.tearDown(); +}); + +/** + * Test that date picker opens to the correct month, with calendar days + * displayed correctly, given a date value is set. + */ +add_task(async function test_datepicker_open() { + const inputValue = "2016-12-15"; + + await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`); + + Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(new Date(inputValue))); + Assert.deepEqual( + getCalendarText(), + [ + "27", "28", "29", "30", "1", "2", "3", + "4", "5", "6", "7", "8", "9", "10", + "11", "12", "13", "14", "15", "16", "17", + "18", "19", "20", "21", "22", "23", "24", + "25", "26", "27", "28", "29", "30", "31", + "1", "2", "3", "4", "5", "6", "7", + ], + "2016-12", + ); + Assert.deepEqual( + getCalendarClassList(), + calendarClasslist_201612, + "2016-12 classNames" + ); + + await helper.tearDown(); +}); + +/** + * When the prev month button is clicked, calendar should display the dates for + * the previous month. + */ +add_task(async function test_datepicker_prev_month_btn() { + const inputValue = "2016-12-15"; + const prevMonth = "2016-11-01"; + + await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`); + helper.click(helper.getElement(BTN_PREV_MONTH)); + + Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(new Date(prevMonth))); + Assert.deepEqual( + getCalendarText(), + [ + "30", "31", "1", "2", "3", "4", "5", + "6", "7", "8", "9", "10", "11", "12", + "13", "14", "15", "16", "17", "18", "19", + "20", "21", "22", "23", "24", "25", "26", + "27", "28", "29", "30", "1", "2", "3", + "4", "5", "6", "7", "8", "9", "10", + ], + "2016-11", + ); + + await helper.tearDown(); +}); + +/** + * When the next month button is clicked, calendar should display the dates for + * the next month. + */ +add_task(async function test_datepicker_next_month_btn() { + const inputValue = "2016-12-15"; + const nextMonth = "2017-01-01"; + + await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`); + helper.click(helper.getElement(BTN_NEXT_MONTH)); + + Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(new Date(nextMonth))); + Assert.deepEqual( + getCalendarText(), + [ + "25", "26", "27", "28", "29", "30", "31", + "1", "2", "3", "4", "5", "6", "7", + "8", "9", "10", "11", "12", "13", "14", + "15", "16", "17", "18", "19", "20", "21", + "22", "23", "24", "25", "26", "27", "28", + "29", "30", "31", "1", "2", "3", "4", + ], + "2017-01", + ); + + await helper.tearDown(); +}); + +/** + * When a date on the calendar is clicked, date picker should close and set + * value to the input box. + */ +add_task(async function test_datepicker_clicked() { + const inputValue = "2016-12-15"; + const firstDayOnCalendar = "2016-11-27"; + + await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`); + // Click the first item (top-left corner) of the calendar + helper.click(helper.getElement(DAYS_VIEW).children[0]); + await ContentTask.spawn(helper.tab.linkedBrowser, {}, async function() { + let inputEl = content.document.querySelector("input"); + await ContentTaskUtils.waitForEvent(inputEl, "input"); + }); + + Assert.equal(content.document.querySelector("input").value, firstDayOnCalendar); + + await helper.tearDown(); +}); + +/** + * Make sure picker is in correct state when it is reopened. + */ +add_task(async function test_datepicker_reopen_state() { + const inputValue = "2016-12-15"; + const nextMonth = "2017-01-01"; + + await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`); + // Navigate to the next month but does not commit the change + Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(new Date(inputValue))); + helper.click(helper.getElement(BTN_NEXT_MONTH)); + Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(new Date(nextMonth))); + EventUtils.synthesizeKey("VK_ESCAPE", {}, window); + + // Ensures the picker opens to the month of the input value + await BrowserTestUtils.synthesizeMouseAtCenter("input", {}, gBrowser.selectedBrowser); + await helper.waitForPickerReady(); + Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(new Date(inputValue))); + + await helper.tearDown(); +}); + +/** + * When min and max attributes are set, calendar should show some dates as + * out-of-range. + */ +add_task(async function test_datepicker_min_max() { + const inputValue = "2016-12-15"; + const inputMin = "2016-12-05"; + const inputMax = "2016-12-25"; + + await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}" min="${inputMin}" max="${inputMax}">`); + + Assert.deepEqual( + getCalendarClassList(), + mergeArrays(calendarClasslist_201612, [ + // R denotes out-of-range + [R], [R], [R], [R], [R], [R], [R], + [R], [], [], [], [], [], [], + [], [], [], [], [], [], [], + [], [], [], [], [], [], [], + [], [R], [R], [R], [R], [R], [R], + [R], [R], [R], [R], [R], [R], [R], + ]), + "2016-12 with min & max", + ); + + await helper.tearDown(); +}); + +/** + * When step attribute is set, calendar should show some dates as off-step. + */ +add_task(async function test_datepicker_step() { + const inputValue = "2016-12-15"; + const inputStep = "5"; + + await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}" step="${inputStep}">`); + + Assert.deepEqual( + getCalendarClassList(), + mergeArrays(calendarClasslist_201612, [ + // P denotes off-step + [P], [P], [P], [], [P], [P], [P], + [P], [], [P], [P], [P], [P], [], + [P], [P], [P], [P], [], [P], [P], + [P], [P], [], [P], [P], [P], [P], + [], [P], [P], [P], [P], [], [P], + [P], [P], [P], [], [P], [P], [P], + ]), + "2016-12 with step", + ); + + await helper.tearDown(); +}); + +add_task(async function test_datepicker_abs_min() { + const inputValue = "0001-01-01"; + await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`); + + Assert.deepEqual( + getCalendarText(), + [ + "", "1", "2", "3", "4", "5", "6", + "7", "8", "9", "10", "11", "12", "13", + "14", "15", "16", "17", "18", "19", "20", + "21", "22", "23", "24", "25", "26", "27", + "28", "29", "30", "31", "1", "2", "3", + "4", "5", "6", "7", "8", "9", "10", + ], + "0001-01", + ); + + await helper.tearDown(); +}); + +add_task(async function test_datepicker_abs_max() { + const inputValue = "275760-09-13"; + await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`); + + Assert.deepEqual( + getCalendarText(), + [ + "31", "1", "2", "3", "4", "5", "6", + "7", "8", "9", "10", "11", "12", "13", + "", "", "", "", "", "", "", + "", "", "", "", "", "", "", + "", "", "", "", "", "", "", + "", "", "", "", "", "", "", + ], + "275760-09", + ); + + await helper.tearDown(); +}); diff --git a/toolkit/content/tests/browser/head.js b/toolkit/content/tests/browser/head.js index 1c6c2b54f..d7ed7a9ff 100644 --- a/toolkit/content/tests/browser/head.js +++ b/toolkit/content/tests/browser/head.js @@ -31,3 +31,93 @@ function pushPrefs(...aPrefs) { SpecialPowers.pushPrefEnv({"set": aPrefs}, deferred.resolve); return deferred.promise; } + +/** + * Helper class for testing datetime input picker widget + */ +class DateTimeTestHelper { + constructor() { + this.panel = document.getElementById("DateTimePickerPanel"); + this.panel.setAttribute("animate", false); + this.tab = null; + this.frame = null; + } + + /** + * Opens a new tab with the URL of the test page, and make sure the picker is + * ready for testing. + * + * @param {String} pageUrl + */ + async openPicker(pageUrl) { + this.tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl); + await BrowserTestUtils.synthesizeMouseAtCenter("input", {}, gBrowser.selectedBrowser); + // If dateTimePopupFrame doesn't exist yet, wait for the binding to be attached + if (!this.panel.dateTimePopupFrame) { + await BrowserTestUtils.waitForEvent(this.panel, "DateTimePickerBindingReady") + } + this.frame = this.panel.dateTimePopupFrame; + await this.waitForPickerReady(); + } + + async waitForPickerReady() { + await BrowserTestUtils.waitForEvent(this.frame, "load", true); + // Wait for picker elements to be ready + await BrowserTestUtils.waitForEvent(this.frame.contentDocument, "PickerReady"); + } + + /** + * Find an element on the picker. + * + * @param {String} selector + * @return {DOMElement} + */ + getElement(selector) { + return this.frame.contentDocument.querySelector(selector); + } + + /** + * Find the children of an element on the picker. + * + * @param {String} selector + * @return {Array<DOMElement>} + */ + getChildren(selector) { + return Array.from(this.getElement(selector).children); + } + + /** + * Click on an element + * + * @param {DOMElement} element + */ + click(element) { + EventUtils.synthesizeMouseAtCenter(element, {}, this.frame.contentWindow); + } + + /** + * Close the panel and the tab + */ + async tearDown() { + if (!this.panel.hidden) { + let pickerClosePromise = new Promise(resolve => { + this.panel.addEventListener("popuphidden", resolve, {once: true}); + }); + this.panel.hidePopup(); + this.panel.closePicker(); + await pickerClosePromise; + } + await BrowserTestUtils.removeTab(this.tab); + this.tab = null; + } + + /** + * Clean up after tests. Remove the frame to prevent leak. + */ + cleanup() { + this.frame.remove(); + this.frame = null; + this.panel.removeAttribute("animate"); + this.panel = null; + } +} diff --git a/toolkit/content/timepicker.xhtml b/toolkit/content/timepicker.xhtml index 1396223f1..77b9fba41 100644 --- a/toolkit/content/timepicker.xhtml +++ b/toolkit/content/timepicker.xhtml @@ -6,7 +6,7 @@ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> <head> <title>Time Picker</title> - <link rel="stylesheet" href="chrome://global/skin/timepicker.css"/> + <link rel="stylesheet" href="chrome://global/skin/datetimeinputpickers.css"/> <script type="application/javascript" src="chrome://global/content/bindings/timekeeper.js"></script> <script type="application/javascript" src="chrome://global/content/bindings/spinner.js"></script> <script type="application/javascript" src="chrome://global/content/bindings/timepicker.js"></script> diff --git a/toolkit/content/widgets/calendar.js b/toolkit/content/widgets/calendar.js new file mode 100644 index 000000000..44ba67501 --- /dev/null +++ b/toolkit/content/widgets/calendar.js @@ -0,0 +1,171 @@ +/* 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/. */ + +"use strict"; + +/** + * Initialize the Calendar and generate nodes for week headers and days, and + * attach event listeners. + * + * @param {Object} options + * { + * {Number} calViewSize: Number of days to appear on a calendar view + * {Function} getDayString: Transform day number to string + * {Function} getWeekHeaderString: Transform day of week number to string + * {Function} setSelection: Set selection for dateKeeper + * } + * @param {Object} context + * { + * {DOMElement} weekHeader + * {DOMElement} daysView + * } + */ +function Calendar(options, context) { + const DAYS_IN_A_WEEK = 7; + + this.context = context; + this.state = { + days: [], + weekHeaders: [], + setSelection: options.setSelection, + getDayString: options.getDayString, + getWeekHeaderString: options.getWeekHeaderString + }; + this.elements = { + weekHeaders: this._generateNodes(DAYS_IN_A_WEEK, context.weekHeader), + daysView: this._generateNodes(options.calViewSize, context.daysView) + }; + + this._attachEventListeners(); +} + +{ + Calendar.prototype = { + + /** + * Set new properties and render them. + * + * @param {Object} props + * { + * {Boolean} isVisible: Whether or not the calendar is in view + * {Array<Object>} days: Data for days + * { + * {Date} dateObj + * {Number} content + * {Array<String>} classNames + * {Boolean} enabled + * } + * {Array<Object>} weekHeaders: Data for weekHeaders + * { + * {Number} content + * {Array<String>} classNames + * } + * } + */ + setProps(props) { + if (props.isVisible) { + // Transform the days and weekHeaders array for rendering + const days = props.days.map(({ dateObj, content, classNames, enabled }) => { + return { + dateObj, + textContent: this.state.getDayString(content), + className: classNames.join(" "), + enabled + }; + }); + const weekHeaders = props.weekHeaders.map(({ content, classNames }) => { + return { + textContent: this.state.getWeekHeaderString(content), + className: classNames.join(" ") + }; + }); + // Update the DOM nodes states + this._render({ + elements: this.elements.daysView, + items: days, + prevState: this.state.days + }); + this._render({ + elements: this.elements.weekHeaders, + items: weekHeaders, + prevState: this.state.weekHeaders, + }); + // Update the state to current + this.state.days = days; + this.state.weekHeaders = weekHeaders; + } + }, + + /** + * Render the items onto the DOM nodes + * @param {Object} + * { + * {Array<DOMElement>} elements + * {Array<Object>} items + * {Array<Object>} prevState: state of items from last render + * } + */ + _render({ elements, items, prevState }) { + for (let i = 0, l = items.length; i < l; i++) { + let el = elements[i]; + + // Check if state from last render has changed, if so, update the elements + if (!prevState[i] || prevState[i].textContent != items[i].textContent) { + el.textContent = items[i].textContent; + } + if (!prevState[i] || prevState[i].className != items[i].className) { + el.className = items[i].className; + } + } + }, + + /** + * Generate DOM nodes + * + * @param {Number} size: Number of nodes to generate + * @param {DOMElement} context: Element to append the nodes to + * @return {Array<DOMElement>} + */ + _generateNodes(size, context) { + let frag = document.createDocumentFragment(); + let refs = []; + + for (let i = 0; i < size; i++) { + let el = document.createElement("div"); + el.dataset.id = i; + refs.push(el); + frag.appendChild(el); + } + context.appendChild(frag); + + return refs; + }, + + /** + * Handle events + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "click": { + if (event.target.parentNode == this.context.daysView) { + let targetId = event.target.dataset.id; + let targetObj = this.state.days[targetId]; + if (targetObj.enabled) { + this.state.setSelection(targetObj.dateObj); + } + } + break; + } + } + }, + + /** + * Attach event listener to daysView + */ + _attachEventListeners() { + this.context.daysView.addEventListener("click", this); + } + }; +} diff --git a/toolkit/content/widgets/datekeeper.js b/toolkit/content/widgets/datekeeper.js new file mode 100644 index 000000000..5d70416a9 --- /dev/null +++ b/toolkit/content/widgets/datekeeper.js @@ -0,0 +1,336 @@ +/* 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/. */ + +"use strict"; + +/** + * DateKeeper keeps track of the date states. + */ +function DateKeeper(props) { + this.init(props); +} + +{ + const DAYS_IN_A_WEEK = 7, + MONTHS_IN_A_YEAR = 12, + YEAR_VIEW_SIZE = 200, + YEAR_BUFFER_SIZE = 10, + // The min value is 0001-01-01 based on HTML spec: + // https://html.spec.whatwg.org/#valid-date-string + MIN_DATE = -62135596800000, + // The max value is derived from the ECMAScript spec (275760-09-13): + // http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.1 + MAX_DATE = 8640000000000000, + MAX_YEAR = 275760, + MAX_MONTH = 9; + + DateKeeper.prototype = { + get year() { + return this.state.dateObj.getUTCFullYear(); + }, + + get month() { + return this.state.dateObj.getUTCMonth(); + }, + + get selection() { + return this.state.selection; + }, + + /** + * Initialize DateKeeper + * @param {Number} year + * @param {Number} month + * @param {Number} day + * @param {Number} min + * @param {Number} max + * @param {Number} step + * @param {Number} stepBase + * @param {Number} firstDayOfWeek + * @param {Array<Number>} weekends + * @param {Number} calViewSize + */ + init({ year, month, day, min, max, step, stepBase, firstDayOfWeek = 0, weekends = [0], calViewSize = 42 }) { + const today = new Date(); + + this.state = { + step, firstDayOfWeek, weekends, calViewSize, + // min & max are NaN if empty or invalid + min: new Date(Number.isNaN(min) ? MIN_DATE : min), + max: new Date(Number.isNaN(max) ? MAX_DATE : max), + stepBase: new Date(stepBase), + today: this._newUTCDate(today.getFullYear(), today.getMonth(), today.getDate()), + weekHeaders: this._getWeekHeaders(firstDayOfWeek, weekends), + years: [], + dateObj: new Date(0), + selection: { year, month, day }, + }; + + this.setCalendarMonth({ + year: year === undefined ? today.getFullYear() : year, + month: month === undefined ? today.getMonth() : month + }); + }, + /** + * Set new calendar month. The year is always treated as full year, so the + * short-form is not supported. + * @param {Object} date parts + * { + * {Number} year [optional] + * {Number} month [optional] + * } + */ + setCalendarMonth({ year = this.year, month = this.month }) { + // Make sure the date is valid before setting. + // Use setUTCFullYear so that year 99 doesn't get parsed as 1999 + if (year > MAX_YEAR || year === MAX_YEAR && month >= MAX_MONTH) { + this.state.dateObj.setUTCFullYear(MAX_YEAR, MAX_MONTH - 1, 1); + } else if (year < 1 || year === 1 && month < 0) { + this.state.dateObj.setUTCFullYear(1, 0, 1); + } else { + this.state.dateObj.setUTCFullYear(year, month, 1); + } + }, + + /** + * Set selection date + * @param {Number} year + * @param {Number} month + * @param {Number} day + */ + setSelection({ year, month, day }) { + this.state.selection.year = year; + this.state.selection.month = month; + this.state.selection.day = day; + }, + + /** + * Set month. Makes sure the day is <= the last day of the month + * @param {Number} month + */ + setMonth(month) { + this.setCalendarMonth({ year: this.year, month }); + }, + + /** + * Set year. Makes sure the day is <= the last day of the month + * @param {Number} year + */ + setYear(year) { + this.setCalendarMonth({ year, month: this.month }); + }, + + /** + * Set month by offset. Makes sure the day is <= the last day of the month + * @param {Number} offset + */ + setMonthByOffset(offset) { + this.setCalendarMonth({ year: this.year, month: this.month + offset }); + }, + + /** + * Generate the array of months + * @return {Array<Object>} + * { + * {Number} value: Month in int + * {Boolean} enabled + * } + */ + getMonths() { + let months = []; + + for (let i = 0; i < MONTHS_IN_A_YEAR; i++) { + months.push({ + value: i, + enabled: true + }); + } + + return months; + }, + + /** + * Generate the array of years + * @return {Array<Object>} + * { + * {Number} value: Year in int + * {Boolean} enabled + * } + */ + getYears() { + let years = []; + + const firstItem = this.state.years[0]; + const lastItem = this.state.years[this.state.years.length - 1]; + const currentYear = this.year; + + // Generate new years array when the year is outside of the first & + // last item range. If not, return the cached result. + if (!firstItem || !lastItem || + currentYear <= firstItem.value + YEAR_BUFFER_SIZE || + currentYear >= lastItem.value - YEAR_BUFFER_SIZE) { + // The year is set in the middle with items on both directions + for (let i = -(YEAR_VIEW_SIZE / 2); i < YEAR_VIEW_SIZE / 2; i++) { + const year = currentYear + i; + if (year >= 1 && year <= MAX_YEAR) { + years.push({ + value: year, + enabled: true + }); + } + } + this.state.years = years; + } + return this.state.years; + }, + + /** + * Get days for calendar + * @return {Array<Object>} + * { + * {Date} dateObj + * {Number} content + * {Array<String>} classNames + * {Boolean} enabled + * } + */ + getDays() { + const firstDayOfMonth = this._getFirstCalendarDate(this.state.dateObj, this.state.firstDayOfWeek); + const month = this.month; + let days = []; + + for (let i = 0; i < this.state.calViewSize; i++) { + const dateObj = this._newUTCDate(firstDayOfMonth.getUTCFullYear(), + firstDayOfMonth.getUTCMonth(), + firstDayOfMonth.getUTCDate() + i); + + let classNames = []; + let enabled = true; + + const isValid = dateObj.getTime() >= MIN_DATE && dateObj.getTime() <= MAX_DATE; + if (!isValid) { + classNames.push("out-of-range"); + enabled = false; + + days.push({ + classNames, + enabled, + }); + continue; + } + + const isWeekend = this.state.weekends.includes(dateObj.getUTCDay()); + const isCurrentMonth = month == dateObj.getUTCMonth(); + const isSelection = this.state.selection.year == dateObj.getUTCFullYear() && + this.state.selection.month == dateObj.getUTCMonth() && + this.state.selection.day == dateObj.getUTCDate(); + const isOutOfRange = dateObj.getTime() < this.state.min.getTime() || + dateObj.getTime() > this.state.max.getTime(); + const isToday = this.state.today.getTime() == dateObj.getTime(); + const isOffStep = this._checkIsOffStep(dateObj, + this._newUTCDate(dateObj.getUTCFullYear(), + dateObj.getUTCMonth(), + dateObj.getUTCDate() + 1)); + + if (isWeekend) { + classNames.push("weekend"); + } + if (!isCurrentMonth) { + classNames.push("outside"); + } + if (isSelection && !isOutOfRange && !isOffStep) { + classNames.push("selection"); + } + if (isOutOfRange) { + classNames.push("out-of-range"); + enabled = false; + } + if (isToday) { + classNames.push("today"); + } + if (isOffStep) { + classNames.push("off-step"); + enabled = false; + } + days.push({ + dateObj, + content: dateObj.getUTCDate(), + classNames, + enabled, + }); + } + return days; + }, + + /** + * Check if a date is off step given a starting point and the next increment + * @param {Date} start + * @param {Date} next + * @return {Boolean} + */ + _checkIsOffStep(start, next) { + // If the increment is larger or equal to the step, it must not be off-step. + if (next - start >= this.state.step) { + return false; + } + // Calculate the last valid date + const lastValidStep = Math.floor((next - 1 - this.state.stepBase) / this.state.step); + const lastValidTimeInMs = lastValidStep * this.state.step + this.state.stepBase.getTime(); + // The date is off-step if the last valid date is smaller than the start date + return lastValidTimeInMs < start.getTime(); + }, + + /** + * Get week headers for calendar + * @param {Number} firstDayOfWeek + * @param {Array<Number>} weekends + * @return {Array<Object>} + * { + * {Number} content + * {Array<String>} classNames + * } + */ + _getWeekHeaders(firstDayOfWeek, weekends) { + let headers = []; + let dayOfWeek = firstDayOfWeek; + + for (let i = 0; i < DAYS_IN_A_WEEK; i++) { + headers.push({ + content: dayOfWeek % DAYS_IN_A_WEEK, + classNames: weekends.includes(dayOfWeek % DAYS_IN_A_WEEK) ? ["weekend"] : [] + }); + dayOfWeek++; + } + return headers; + }, + + /** + * Get the first day on a calendar month + * @param {Date} dateObj + * @param {Number} firstDayOfWeek + * @return {Date} + */ + _getFirstCalendarDate(dateObj, firstDayOfWeek) { + const daysOffset = 1 - DAYS_IN_A_WEEK; + let firstDayOfMonth = this._newUTCDate(dateObj.getUTCFullYear(), dateObj.getUTCMonth()); + let dayOfWeek = firstDayOfMonth.getUTCDay(); + + return this._newUTCDate( + firstDayOfMonth.getUTCFullYear(), + firstDayOfMonth.getUTCMonth(), + // When first calendar date is the same as first day of the week, add + // another row on top of it. + firstDayOfWeek == dayOfWeek ? daysOffset : (firstDayOfWeek - dayOfWeek + daysOffset) % DAYS_IN_A_WEEK); + }, + + /** + * Helper function for creating UTC dates + * @param {...[Number]} parts + * @return {Date} + */ + _newUTCDate(...parts) { + return new Date(new Date(0).setUTCFullYear(...parts)); + }, + }; +} diff --git a/toolkit/content/widgets/datepicker.js b/toolkit/content/widgets/datepicker.js new file mode 100644 index 000000000..0e9c9a6e6 --- /dev/null +++ b/toolkit/content/widgets/datepicker.js @@ -0,0 +1,376 @@ +/* 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/. */ + +"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(); + document.dispatchEvent(new CustomEvent("PickerReady")); + }, + + /* + * Set initial date picker states. + */ + _setDefaultState() { + const { year, month, day, min, max, step, stepBase, firstDayOfWeek, weekends, + monthStrings, weekdayStrings, locale } = this.props; + const dateKeeper = new DateKeeper({ + year, month, day, min, max, step, stepBase, firstDayOfWeek, weekends, + calViewSize: CAL_VIEW_SIZE + }); + + this.state = { + dateKeeper, + locale, + isMonthPickerVisible: false, + 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(); + }, + 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, + 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, + 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; + + 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 + }); + + isMonthPickerVisible ? + this.context.monthYearView.classList.remove("hidden") : + this.context.monthYearView.classList.add("hidden"); + }, + + /** + * Use postMessage to close the picker. + */ + _closePopup() { + window.postMessage({ + name: "ClosePopup" + }, "*"); + }, + + /** + * 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); + }, + + /** + * Handle events. + * + * @param {Event} event + */ + handleEvent(event) { + switch (event.type) { + case "message": { + this.handleMessage(event); + break; + } + case "mousedown": { + // Use preventDefault to keep focus on input boxes + event.preventDefault(); + event.target.setCapture(); + + if (event.target == this.context.buttonLeft) { + event.target.classList.add("active"); + this.state.dateKeeper.setMonthByOffset(-1); + this._update(); + } else if (event.target == this.context.buttonRight) { + event.target.classList.add("active"); + this.state.dateKeeper.setMonthByOffset(1); + this._update(); + } + break; + } + case "mouseup": { + if (event.target == this.context.buttonLeft || event.target == this.context.buttonRight) { + event.target.classList.remove("active"); + } + + } + } + }, + + /** + * 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 }) { + 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 + * } + * @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; + this.context = context; + this.state = { dateFormat }; + this.props = {}; + this.components = { + month: new Spinner({ + setValue: month => { + this.state.isMonthSet = true; + options.setMonth(month); + }, + getDisplayString: options.getMonthString, + viewportSize: spinnerSize + }, context.monthYearView), + year: new Spinner({ + setValue: year => { + this.state.isYearSet = true; + options.setYear(year); + }, + getDisplayString: year => yearFormat(new Date(new Date(0).setUTCFullYear(year))), + viewportSize: spinnerSize + }, context.monthYearView) + }; + + 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); + + if (props.isVisible) { + this.context.monthYear.classList.add("active"); + 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; + } else { + this.context.monthYear.classList.remove("active"); + 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; + } + } + }, + + /** + * Attach event listener to monthYear button + */ + _attachEventListeners() { + this.context.monthYear.addEventListener("click", this); + } + }; +} diff --git a/toolkit/content/widgets/datetimebox.css b/toolkit/content/widgets/datetimebox.css index 4a9593a69..ce638078f 100644 --- a/toolkit/content/widgets/datetimebox.css +++ b/toolkit/content/widgets/datetimebox.css @@ -8,9 +8,17 @@ .datetime-input-box-wrapper { -moz-appearance: none; display: inline-flex; + flex: 1; cursor: default; background-color: inherit; color: inherit; + min-width: 0; + justify-content: space-between; +} + +.datetime-input-edit-wrapper { + overflow: hidden; + white-space: nowrap; } .datetime-input { @@ -20,6 +28,8 @@ border: 0; margin: 0; ime-mode: disabled; + cursor: default; + -moz-user-select: none; } .datetime-separator { @@ -41,5 +51,5 @@ height: 12px; width: 12px; align-self: center; - justify-content: flex-end; + flex: none; } diff --git a/toolkit/content/widgets/datetimebox.xml b/toolkit/content/widgets/datetimebox.xml index 05591e65a..94574038a 100644 --- a/toolkit/content/widgets/datetimebox.xml +++ b/toolkit/content/widgets/datetimebox.xml @@ -4,12 +4,466 @@ - 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/. --> +<!DOCTYPE bindings [ +<!ENTITY % datetimeboxDTD SYSTEM "chrome://global/locale/datetimebox.dtd"> +%datetimeboxDTD; +]> + +<!-- +TODO +Bug 1446342: +Input type="date" not working if the other form elements has name="document" + +Any alternative solution: +document === window.document +document === this.ownerDocument +--> + <bindings id="datetimeboxBindings" xmlns="http://www.mozilla.org/xbl" xmlns:html="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:xbl="http://www.mozilla.org/xbl"> + <binding id="date-input" + extends="chrome://global/content/bindings/datetimebox.xml#datetime-input-base"> + <resources> + <stylesheet src="chrome://global/content/textbox.css"/> + <stylesheet src="chrome://global/skin/textbox.css"/> + <stylesheet src="chrome://global/content/bindings/datetimebox.css"/> + </resources> + + <implementation> + <constructor> + <![CDATA[ + /* eslint-disable no-multi-spaces */ + this.mYearPlaceHolder = ]]>"&date.year.placeholder;"<![CDATA[; + this.mMonthPlaceHolder = ]]>"&date.month.placeholder;"<![CDATA[; + this.mDayPlaceHolder = ]]>"&date.day.placeholder;"<![CDATA[; + this.mSeparatorText = "/"; + /* eslint-enable no-multi-spaces */ + + this.mMinMonth = 1; + this.mMaxMonth = 12; + this.mMinDay = 1; + this.mMaxDay = 31; + this.mMinYear = 1; + // Maximum year limited by ECMAScript date object range, year <= 275760. + this.mMaxYear = 275760; + this.mMonthDayLength = 2; + this.mYearLength = 4; + this.mMonthPageUpDownInterval = 3; + this.mDayPageUpDownInterval = 7; + this.mYearPageUpDownInterval = 10; + + // Default to en-US, month-day-year order. + this.mMonthField = + window.document.getAnonymousElementByAttribute(this, "anonid", "input-one"); + this.mDayField = + window.document.getAnonymousElementByAttribute(this, "anonid", "input-two"); + this.mYearField = + window.document.getAnonymousElementByAttribute(this, "anonid", "input-three"); + this.mYearField.size = this.mYearLength; + this.mYearField.maxLength = this.mMaxYear.toString().length; + + this.mMonthField.placeholder = this.mMonthPlaceHolder; + this.mDayField.placeholder = this.mDayPlaceHolder; + this.mYearField.placeholder = this.mYearPlaceHolder; + + this.mMonthField.setAttribute("min", this.mMinMonth); + this.mMonthField.setAttribute("max", this.mMaxMonth); + this.mMonthField.setAttribute("pginterval", + this.mMonthPageUpDownInterval); + this.mDayField.setAttribute("min", this.mMinDay); + this.mDayField.setAttribute("max", this.mMaxDay); + this.mDayField.setAttribute("pginterval", this.mDayPageUpDownInterval); + this.mYearField.setAttribute("min", this.mMinYear); + this.mYearField.setAttribute("max", this.mMaxYear); + this.mYearField.setAttribute("pginterval", + this.mYearPageUpDownInterval); + + this.mDaySeparator = + window.document.getAnonymousElementByAttribute(this, "anonid", "sep-first"); + this.mDaySeparator.textContent = this.mSeparatorText; + this.mYearSeparator = + window.document.getAnonymousElementByAttribute(this, "anonid", "sep-second"); + this.mYearSeparator.textContent = this.mSeparatorText; + + if (this.mInputElement.value) { + this.setFieldsFromInputValue(); + } + this.updateResetButtonVisibility(); + ]]> + </constructor> + + <method name="clearInputFields"> + <parameter name="aFromInputElement"/> + <body> + <![CDATA[ + this.log("clearInputFields"); + + if (this.isDisabled() || this.isReadonly()) { + return; + } + + if (this.mMonthField && !this.mMonthField.disabled && + !this.mMonthField.readOnly) { + this.mMonthField.value = ""; + this.mMonthField.setAttribute("typeBuffer", ""); + } + + if (this.mDayField && !this.mDayField.disabled && + !this.mDayField.readOnly) { + this.mDayField.value = ""; + this.mDayField.setAttribute("typeBuffer", ""); + } + + if (this.mYearField && !this.mYearField.disabled && + !this.mYearField.readOnly) { + this.mYearField.value = ""; + this.mYearField.setAttribute("typeBuffer", ""); + } + + if (!aFromInputElement && this.mInputElement.value) { + this.mInputElement.setUserInput(""); + } + + this.updateResetButtonVisibility(); + ]]> + </body> + </method> + + <method name="setFieldsFromInputValue"> + <body> + <![CDATA[ + let value = this.mInputElement.value; + if (!value) { + this.clearInputFields(true); + return; + } + + this.log("setFieldsFromInputValue: " + value); + let [year, month, day] = value.split("-"); + + this.setFieldValue(this.mYearField, year); + this.setFieldValue(this.mMonthField, month); + this.setFieldValue(this.mDayField, day); + + this.notifyPicker(); + ]]> + </body> + </method> + + <method name="getDaysInMonth"> + <parameter name="aMonth"/> + <parameter name="aYear"/> + <body> + <![CDATA[ + // Javascript's month is 0-based, so this means last day of the + // previous month. + return new Date(aYear, aMonth, 0).getDate(); + ]]> + </body> + </method> + + <method name="isFieldInvalid"> + <parameter name="aField"/> + <body> + <![CDATA[ + if (this.isEmpty(aField.value)) { + return true; + } + + let min = Number(aField.getAttribute("min")); + let max = Number(aField.getAttribute("max")); + + if (Number(aField.value) < min || Number(aField.value) > max) { + return true; + } + + return false; + ]]> + </body> + </method> + + <method name="setInputValueFromFields"> + <body> + <![CDATA[ + if (!this.isAnyValueAvailable(false) && this.mInputElement.value) { + // Values in the input box was cleared, clear the input element's + // value if not empty. + this.mInputElement.setUserInput(""); + return; + } + + if (this.isFieldInvalid(this.mYearField) || + this.isFieldInvalid(this.mMonthField) || + this.isFieldInvalid(this.mDayField)) { + // We still need to notify picker in case any of the field has + // changed. If we can set input element value, then notifyPicker + // will be called in setFieldsFromInputValue(). + this.notifyPicker(); + return; + } + + let year = this.mYearField.value; + let month = this.mMonthField.value; + let day = this.mDayField.value; + + if (day > this.getDaysInMonth(month, year)) { + // Don't set invalid date, otherwise input element's value will be + // set to empty. + return; + } + + let date = [year, month, day].join("-"); + + if (date == this.mInputElement.value) { + return; + } + + this.log("setInputValueFromFields: " + date); + this.mInputElement.setUserInput(date); + ]]> + </body> + </method> + + <method name="setFieldsFromPicker"> + <parameter name="aValue"/> + <body> + <![CDATA[ + let year = aValue.year; + let month = aValue.month; + let day = aValue.day; + + if (!this.isEmpty(year)) { + this.setFieldValue(this.mYearField, year); + } + + if (!this.isEmpty(month)) { + this.setFieldValue(this.mMonthField, month); + } + + if (!this.isEmpty(day)) { + this.setFieldValue(this.mDayField, day); + } + + // Update input element's .value if needed. + this.setInputValueFromFields(); + ]]> + </body> + </method> + + <method name="handleKeypress"> + <parameter name="aEvent"/> + <body> + <![CDATA[ + if (this.isDisabled() || this.isReadonly()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + if (targetField.classList.contains("numeric") && key.match(/[0-9]/)) { + let buffer = targetField.getAttribute("typeBuffer") || ""; + + buffer = buffer.concat(key); + this.setFieldValue(targetField, buffer); + targetField.select(); + + let n = Number(buffer); + let max = targetField.getAttribute("max"); + if (buffer.length >= targetField.maxLength || n * 10 > max) { + buffer = ""; + this.advanceToNextField(); + } + targetField.setAttribute("typeBuffer", buffer); + } + ]]> + </body> + </method> + + <method name="incrementFieldValue"> + <parameter name="aTargetField"/> + <parameter name="aTimes"/> + <body> + <![CDATA[ + let value; + + // Use current date if field is empty. + if (this.isEmpty(aTargetField.value)) { + let now = new Date(); + + if (aTargetField == this.mYearField) { + value = now.getFullYear(); + } else if (aTargetField == this.mMonthField) { + value = now.getMonth() + 1; + } else if (aTargetField == this.mDayField) { + value = now.getDate(); + } else { + this.log("Field not supported in incrementFieldValue."); + return; + } + } else { + value = Number(aTargetField.value); + } + + let min = Number(aTargetField.getAttribute("min")); + let max = Number(aTargetField.getAttribute("max")); + + value += Number(aTimes); + if (value > max) { + value -= (max - min + 1); + } else if (value < min) { + value += (max - min + 1); + } + this.setFieldValue(aTargetField, value); + aTargetField.select(); + ]]> + </body> + </method> + + <method name="handleKeyboardNav"> + <parameter name="aEvent"/> + <body> + <![CDATA[ + if (this.isDisabled() || this.isReadonly()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + // Home/End key does nothing on year field. + if (targetField == this.mYearField && (key == "Home" || + key == "End")) { + return; + } + + switch (key) { + case "ArrowUp": + this.incrementFieldValue(targetField, 1); + break; + case "ArrowDown": + this.incrementFieldValue(targetField, -1); + break; + case "PageUp": { + let interval = targetField.getAttribute("pginterval"); + this.incrementFieldValue(targetField, interval); + break; + } + case "PageDown": { + let interval = targetField.getAttribute("pginterval"); + this.incrementFieldValue(targetField, 0 - interval); + break; + } + case "Home": + let min = targetField.getAttribute("min"); + this.setFieldValue(targetField, min); + targetField.select(); + break; + case "End": + let max = targetField.getAttribute("max"); + this.setFieldValue(targetField, max); + targetField.select(); + break; + } + this.setInputValueFromFields(); + ]]> + </body> + </method> + + <method name="getCurrentValue"> + <body> + <![CDATA[ + let year; + if (!this.isEmpty(this.mYearField.value)) { + year = Number(this.mYearField.value); + } + + let month; + if (!this.isEmpty(this.mMonthField.value)) { + month = Number(this.mMonthField.value); + } + + let day; + if (!this.isEmpty(this.mDayField.value)) { + day = Number(this.mDayField.value); + } + + let date = { year, month, day }; + + this.log("getCurrentValue: " + JSON.stringify(date)); + return date; + ]]> + </body> + </method> + + <method name="setFieldValue"> + <parameter name="aField"/> + <parameter name="aValue"/> + <body> + <![CDATA[ + let value = Number(aValue); + if (isNaN(value)) { + this.log("NaN on setFieldValue!"); + return; + } + + if (aValue.length == aField.maxLength) { + let min = Number(aField.getAttribute("min")); + let max = Number(aField.getAttribute("max")); + + if (aValue < min) { + value = min; + } else if (aValue > max) { + value = max; + } + } + + if (aField == this.mMonthField || + aField == this.mDayField) { + // prepend zero + if (value < 10) { + value = "0" + value; + } + } else { + // prepend zeroes + if (value < 10) { + value = "000" + value; + } else if (value < 100) { + value = "00" + value; + } else if (value < 1000) { + value = "0" + value; + } + + if (value.toString().length > this.mYearLength && + value.toString().length <= this.mMaxYear.toString().length) { + this.mYearField.size = value.toString().length; + } + } + + aField.value = value; + this.updateResetButtonVisibility(); + ]]> + </body> + </method> + + <method name="isAnyValueAvailable"> + <parameter name="aForPicker"/> + <body> + <![CDATA[ + return !this.isEmpty(this.mMonthField.value) || + !this.isEmpty(this.mDayField.value) || + !this.isEmpty(this.mYearField.value); + ]]> + </body> + </method> + + </implementation> + </binding> + <binding id="time-input" extends="chrome://global/content/bindings/datetimebox.xml#datetime-input-base"> <resources> @@ -45,13 +499,13 @@ this.mMinSecPageUpDownInterval = 10; this.mHourField = - document.getAnonymousElementByAttribute(this, "anonid", "input-one"); + window.document.getAnonymousElementByAttribute(this, "anonid", "input-one"); this.mHourField.setAttribute("typeBuffer", ""); this.mMinuteField = - document.getAnonymousElementByAttribute(this, "anonid", "input-two"); + window.document.getAnonymousElementByAttribute(this, "anonid", "input-two"); this.mMinuteField.setAttribute("typeBuffer", ""); this.mDayPeriodField = - document.getAnonymousElementByAttribute(this, "anonid", "input-three"); + window.document.getAnonymousElementByAttribute(this, "anonid", "input-three"); this.mDayPeriodField.classList.remove("numeric"); this.mHourField.placeholder = this.mPlaceHolder; @@ -64,10 +518,10 @@ this.mMinuteField.setAttribute("max", this.mMaxMinute); this.mMinuteSeparator = - document.getAnonymousElementByAttribute(this, "anonid", "sep-first"); + window.document.getAnonymousElementByAttribute(this, "anonid", "sep-first"); this.mMinuteSeparator.textContent = this.mSeparatorText; this.mSpaceSeparator = - document.getAnonymousElementByAttribute(this, "anonid", "sep-second"); + window.document.getAnonymousElementByAttribute(this, "anonid", "sep-second"); // space between time and am/pm field this.mSpaceSeparator.textContent = " "; @@ -79,6 +533,7 @@ if (this.mInputElement.value) { this.setFieldsFromInputValue(); } + this.updateResetButtonVisibility(); ]]> </constructor> @@ -138,7 +593,7 @@ } this.log("setFieldsFromInputValue: " + value); - let [hour, minute, second] = value.split(':'); + let [hour, minute, second] = value.split(":"); this.setFieldValue(this.mHourField, hour); this.setFieldValue(this.mMinuteField, minute); @@ -204,6 +659,13 @@ <method name="setInputValueFromFields"> <body> <![CDATA[ + if (!this.isAnyValueAvailable(false) && this.mInputElement.value) { + // Values in the input box was cleared, clear the input element's + // value if not empty. + this.mInputElement.setUserInput(""); + return; + } + if (this.isEmpty(this.mHourField.value) || this.isEmpty(this.mMinuteField.value) || (this.mDayPeriodField && this.isEmpty(this.mDayPeriodField.value)) || @@ -239,6 +701,10 @@ time += "." + this.mMillisecField.value; } + if (time == this.mInputElement.value) { + return; + } + this.log("setInputValueFromFields: " + time); this.mInputElement.setUserInput(time); ]]> @@ -265,6 +731,9 @@ if (!this.isEmpty(minute)) { this.setFieldValue(this.mMinuteField, minute); } + + // Update input element's .value if needed. + this.setInputValueFromFields(); ]]> </body> </method> @@ -282,21 +751,25 @@ if (this.mHourField && !this.mHourField.disabled && !this.mHourField.readOnly) { this.mHourField.value = ""; + this.mHourField.setAttribute("typeBuffer", ""); } if (this.mMinuteField && !this.mMinuteField.disabled && !this.mMinuteField.readOnly) { this.mMinuteField.value = ""; + this.mMinuteField.setAttribute("typeBuffer", ""); } if (this.mSecondField && !this.mSecondField.disabled && !this.mSecondField.readOnly) { this.mSecondField.value = ""; + this.mSecondField.setAttribute("typeBuffer", ""); } if (this.mMillisecField && !this.mMillisecField.disabled && !this.mMillisecField.readOnly) { this.mMillisecField.value = ""; + this.mMillisecField.setAttribute("typeBuffer", ""); } if (this.mDayPeriodField && !this.mDayPeriodField.disabled && @@ -304,9 +777,11 @@ this.mDayPeriodField.value = ""; } - if (!aFromInputElement) { + if (!aFromInputElement && this.mInputElement.value) { this.mInputElement.setUserInput(""); } + + this.updateResetButtonVisibility(); ]]> </body> </method> @@ -376,6 +851,7 @@ this.mDayPeriodField.value == this.mAMIndicator ? this.mPMIndicator : this.mAMIndicator; this.mDayPeriodField.select(); + this.updateResetButtonVisibility(); this.setInputValueFromFields(); return; } @@ -433,6 +909,7 @@ this.mDayPeriodField.value = this.mPMIndicator; this.mDayPeriodField.select(); } + this.updateResetButtonVisibility(); return; } @@ -488,16 +965,30 @@ } aField.value = value; + this.updateResetButtonVisibility(); ]]> </body> </method> - <method name="isValueAvailable"> + <method name="isAnyValueAvailable"> + <parameter name="aForPicker"/> <body> <![CDATA[ + let available = !this.isEmpty(this.mHourField.value) || + !this.isEmpty(this.mMinuteField.value); + + if (available) { + return true; + } + // Picker only cares about hour:minute. - return !this.isEmpty(this.mHourField.value) || - !this.isEmpty(this.mMinuteField.value); + if (aForPicker) { + return false; + } + + return (this.mDayPeriodField && !this.isEmpty(this.mDayPeriodField.value)) || + (this.mSecondField && !this.isEmpty(this.mSecondField.value)) || + (this.mMillisecField && !this.isEmpty(this.mMillisecField.value)); ]]> </body> </method> @@ -546,7 +1037,8 @@ <content> <html:div class="datetime-input-box-wrapper" xbl:inherits="context,disabled,readonly"> - <html:span> + <html:span class="datetime-input-edit-wrapper" + anonid="edit-wrapper"> <html:input anonid="input-one" class="textbox-input datetime-input numeric" size="2" maxlength="2" @@ -563,9 +1055,8 @@ xbl:inherits="disabled,readonly,tabindex"/> </html:span> - <html:button class="datetime-reset-button" anoid="reset-button" - tabindex="-1" xbl:inherits="disabled" - onclick="document.getBindingParent(this).clearInputFields(false);"/> + <html:button class="datetime-reset-button" anonid="reset-button" + tabindex="-1" xbl:inherits="disabled"/> </html:div> </content> @@ -579,9 +1070,49 @@ this.mMax = this.mInputElement.max; this.mStep = this.mInputElement.step; this.mIsPickerOpen = false; + + this.mResetButton = + window.document.getAnonymousElementByAttribute(this, "anonid", "reset-button"); + + this.EVENTS.forEach((eventName) => { + this.addEventListener(eventName, this, { mozSystemGroup: true }); + }); + // Handle keypress separately since we need to catch it on capturing. + this.addEventListener("keypress", this, { + capture: true, + mozSystemGroup: true + }); + // This is to open the picker when input element is clicked (this + // includes padding area). + this.mInputElement.addEventListener("click", this, + { mozSystemGroup: true }); ]]> </constructor> + <destructor> + <![CDATA[ + this.EVENTS.forEach((eventName) => { + this.removeEventListener(eventName, this, { mozSystemGroup: true }); + }); + this.removeEventListener("keypress", this, { + capture: true, + mozSystemGroup: true + }); + this.mInputElement.removeEventListener("click", this, + { mozSystemGroup: true }); + + this.mInputElement = null; + ]]> + </destructor> + + <property name="EVENTS" readonly="true"> + <getter> + <![CDATA[ + return ["focus", "blur", "copy", "cut", "paste", "mousedown"]; + ]]> + </getter> + </property> + <method name="log"> <parameter name="aMsg"/> <body> @@ -593,11 +1124,23 @@ </body> </method> + <method name="updateResetButtonVisibility"> + <body> + <![CDATA[ + if (this.isAnyValueAvailable(false)) { + this.mResetButton.style.visibility = "visible"; + } else { + this.mResetButton.style.visibility = "hidden"; + } + ]]> + </body> + </method> + <method name="focusInnerTextBox"> <body> <![CDATA[ this.log("focusInnerTextBox"); - document.getAnonymousElementByAttribute(this, "anonid", "input-one").focus(); + window.document.getAnonymousElementByAttribute(this, "anonid", "input-one").focus(); ]]> </body> </method> @@ -710,10 +1253,22 @@ </body> </method> + <method name="getCurrentValue"> + <body> + throw Components.results.NS_ERROR_NOT_IMPLEMENTED; + </body> + </method> + + <method name="isAnyValueAvailable"> + <body> + throw Components.results.NS_ERROR_NOT_IMPLEMENTED; + </body> + </method> + <method name="notifyPicker"> <body> <![CDATA[ - if (this.mIsPickerOpen && this.isValueAvailable()) { + if (this.mIsPickerOpen && this.isAnyValueAvailable(true)) { this.mInputElement.updateDateTimePicker(this.getCurrentValue()); } ]]> @@ -736,72 +1291,153 @@ </body> </method> - </implementation> - - <handlers> - <handler event="focus"> - <![CDATA[ - this.log("focus on: " + event.originalTarget); + <method name="handleEvent"> + <parameter name="aEvent"/> + <body> + <![CDATA[ + this.log("handleEvent: " + aEvent.type); - let target = event.originalTarget; - if (target.type == "text") { - this.mLastFocusedField = target; - target.select(); - } - ]]> - </handler> + switch (aEvent.type) { + case "keypress": { + this.onKeyPress(aEvent); + break; + } + case "click": { + this.onClick(aEvent); + break; + } + case "focus": { + this.onFocus(aEvent); + break; + } + case "blur": { + this.onBlur(aEvent); + break; + } + case "mousedown": { + if (aEvent.originalTarget == this.mResetButton) { + aEvent.preventDefault(); + } + break; + } + case "copy": + case "cut": + case "paste": { + aEvent.preventDefault(); + break; + } + default: + break; + } + ]]> + </body> + </method> - <handler event="blur"> - <![CDATA[ - this.setInputValueFromFields(); - ]]> - </handler> + <method name="onFocus"> + <parameter name="aEvent"/> + <body> + <![CDATA[ + this.log("onFocus originalTarget: " + aEvent.originalTarget); - <handler event="click"> - <![CDATA[ - // XXX: .originalTarget is not expected. - // When clicking on one of the inner text boxes, the .originalTarget is - // a HTMLDivElement and when clicking on the reset button, it's a - // HTMLButtonElement but it's not equal to our reset-button. - this.log("click on: " + event.originalTarget); - if (event.defaultPrevented || this.isDisabled() || this.isReadonly()) { - return; - } + let target = aEvent.originalTarget; + if ((target instanceof HTMLInputElement) && target.type == "text") { + this.mLastFocusedField = target; + target.select(); + } + ]]> + </body> + </method> - if (!(event.originalTarget instanceof HTMLButtonElement)) { - this.mInputElement.openDateTimePicker(this.getCurrentValue()); - } - ]]> - </handler> + <method name="onBlur"> + <parameter name="aEvent"/> + <body> + <![CDATA[ + this.log("onBlur originalTarget: " + aEvent.originalTarget + + " target: " + aEvent.target); - <handler event="keypress" phase="capturing"> - <![CDATA[ - let key = event.key; - this.log("keypress: " + key); + let target = aEvent.originalTarget; + target.setAttribute("typeBuffer", ""); + this.setInputValueFromFields(); + ]]> + </body> + </method> - if (key == "Backspace" || key == "Tab") { - return; - } + <method name="onKeyPress"> + <parameter name="aEvent"/> + <body> + <![CDATA[ + this.log("onKeyPress key: " + aEvent.key); + + switch (aEvent.key) { + // Close picker on Enter, Escape or Space key. + case "Enter": + case "Escape": + case " ": { + if (this.mIsPickerOpen) { + this.mInputElement.closeDateTimePicker(); + aEvent.preventDefault(); + } + break; + } + case "Backspace": { + let targetField = aEvent.originalTarget; + targetField.value = ""; + targetField.setAttribute("typeBuffer", ""); + this.updateResetButtonVisibility(); + this.setInputValueFromFields(); + aEvent.preventDefault(); + break; + } + case "ArrowRight": + case "ArrowLeft": { + this.advanceToNextField(aEvent.key == "ArrowRight" ? false : true); + aEvent.preventDefault(); + break; + } + case "ArrowUp": + case "ArrowDown": + case "PageUp": + case "PageDown": + case "Home": + case "End": { + this.handleKeyboardNav(aEvent); + aEvent.preventDefault(); + break; + } + default: { + // printable characters + if (aEvent.keyCode == 0 && + !(aEvent.ctrlKey || aEvent.altKey || aEvent.metaKey)) { + this.handleKeypress(aEvent); + aEvent.preventDefault(); + } + break; + } + } + ]]> + </body> + </method> - if (key == "Enter" || key == " ") { - // Close picker on Enter and Space. - this.mInputElement.closeDateTimePicker(); - } + <method name="onClick"> + <parameter name="aEvent"/> + <body> + <![CDATA[ + this.log("onClick originalTarget: " + aEvent.originalTarget + + " target: " + aEvent.target); - if (key == "ArrowUp" || key == "ArrowDown" || - key == "PageUp" || key == "PageDown" || - key == "Home" || key == "End") { - this.handleKeyboardNav(event); - } else if (key == "ArrowRight" || key == "ArrowLeft") { - this.advanceToNextField((key == "ArrowRight" ? false : true)); - } else { - this.handleKeypress(event); - } + if (aEvent.defaultPrevented || this.isDisabled() || this.isReadonly()) { + return; + } - event.preventDefault(); - ]]> - </handler> - </handlers> + if (aEvent.originalTarget == this.mResetButton) { + this.clearInputFields(false); + } else if (!this.mIsPickerOpen) { + this.mInputElement.openDateTimePicker(this.getCurrentValue()); + } + ]]> + </body> + </method> + </implementation> </binding> </bindings> diff --git a/toolkit/content/widgets/datetimepicker.xml b/toolkit/content/widgets/datetimepicker.xml index 5f16f1ff0..1d6a5e772 100644 --- a/toolkit/content/widgets/datetimepicker.xml +++ b/toolkit/content/widgets/datetimepicker.xml @@ -999,13 +999,13 @@ <body> <![CDATA[ var locale = Intl.DateTimeFormat().resolvedOptions().locale + "-u-ca-gregory"; - var dtfMonth = Intl.DateTimeFormat(locale, {month: "long"}); + var dtfMonth = Intl.DateTimeFormat(locale, {month: "long", timeZone: "UTC"}); var dtfWeekday = Intl.DateTimeFormat(locale, {weekday: "narrow"}); var monthLabel = this.monthField.firstChild; - var tempDate = new Date(2005, 0, 1); + var tempDate = new Date(Date.UTC(2005, 0, 1)); for (var month = 0; month < 12; month++) { - tempDate.setMonth(month); + tempDate.setUTCMonth(month); monthLabel.setAttribute("value", dtfMonth.format(tempDate)); monthLabel = monthLabel.nextSibling; } diff --git a/toolkit/content/widgets/datetimepopup.xml b/toolkit/content/widgets/datetimepopup.xml index 327f45368..b4335e1ce 100644 --- a/toolkit/content/widgets/datetimepopup.xml +++ b/toolkit/content/widgets/datetimepopup.xml @@ -11,17 +11,31 @@ xmlns:xbl="http://www.mozilla.org/xbl"> <binding id="datetime-popup" extends="chrome://global/content/bindings/popup.xml#arrowpanel"> + <resources> + <stylesheet src="chrome://global/skin/datetimepopup.css"/> + </resources> <implementation> <field name="dateTimePopupFrame"> this.querySelector("#dateTimePopupFrame"); </field> <field name="TIME_PICKER_WIDTH" readonly="true">"12em"</field> <field name="TIME_PICKER_HEIGHT" readonly="true">"21em"</field> - <method name="loadPicker"> + <field name="DATE_PICKER_WIDTH" readonly="true">"23.1em"</field> + <field name="DATE_PICKER_HEIGHT" readonly="true">"20.7em"</field> + <constructor><![CDATA[ + this.l10n = {}; + const mozIntl = Components.classes["@mozilla.org/mozintl;1"] + .getService(Components.interfaces.mozIMozIntl); + mozIntl.addGetCalendarInfo(l10n); + mozIntl.addGetDisplayNames(l10n); + // Notify DateTimePickerHelper.jsm that binding is ready. + this.dispatchEvent(new CustomEvent("DateTimePickerBindingReady")); + ]]></constructor> + <method name="openPicker"> <parameter name="type"/> + <parameter name="anchor"/> <parameter name="detail"/> <body><![CDATA[ - this.hidden = false; this.type = type; this.pickerState = {}; // TODO: Resize picker according to content zoom level @@ -35,18 +49,28 @@ this.dateTimePopupFrame.style.height = this.TIME_PICKER_HEIGHT; break; } + case "date": { + this.detail = detail; + this.dateTimePopupFrame.addEventListener("load", this, true); + this.dateTimePopupFrame.setAttribute("src", "chrome://global/content/datepicker.xhtml"); + this.dateTimePopupFrame.style.width = this.DATE_PICKER_WIDTH; + this.dateTimePopupFrame.style.height = this.DATE_PICKER_HEIGHT; + break; + } } + this.hidden = false; + this.openPopup(anchor, "after_start", 0, 0); ]]></body> </method> <method name="closePicker"> <body><![CDATA[ - this.hidden = true; this.setInputBoxValue(true); this.pickerState = {}; this.type = undefined; this.dateTimePopupFrame.removeEventListener("load", this, true); - this.dateTimePopupFrame.contentDocument.removeEventListener("TimePickerPopupChanged", this, false); + this.dateTimePopupFrame.contentDocument.removeEventListener("message", this, false); this.dateTimePopupFrame.setAttribute("src", ""); + this.hidden = true; ]]></body> </method> <method name="setPopupValue"> @@ -55,25 +79,39 @@ switch (this.type) { case "time": { this.postMessageToPicker({ - name: "TimePickerSetValue", + name: "PickerSetValue", detail: data.value }); break; } + case "date": { + const { year, month, day } = data.value; + this.postMessageToPicker({ + name: "PickerSetValue", + detail: { + year, + // Month value from input box starts from 1 instead of 0 + month: month == undefined ? undefined : month - 1, + day + } + }); + break; + } } ]]></body> </method> <method name="initPicker"> <parameter name="detail"/> <body><![CDATA[ + const locale = Components.classes["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry).getSelectedLocale("global"); + switch (this.type) { case "time": { const { hour, minute } = detail.value; const format = detail.format || "12"; - const locale = Components.classes["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry).getSelectedLocale("global"); this.postMessageToPicker({ - name: "TimePickerInit", + name: "PickerInit", detail: { hour, minute, @@ -86,6 +124,56 @@ }); break; } + case "date": { + const { year, month, day } = detail.value; + const { firstDayOfWeek, weekends } = + this.getCalendarInfo(locale); + const monthStrings = this.getDisplayNames( + locale, [ + "dates/gregorian/months/january", + "dates/gregorian/months/february", + "dates/gregorian/months/march", + "dates/gregorian/months/april", + "dates/gregorian/months/may", + "dates/gregorian/months/june", + "dates/gregorian/months/july", + "dates/gregorian/months/august", + "dates/gregorian/months/september", + "dates/gregorian/months/october", + "dates/gregorian/months/november", + "dates/gregorian/months/december", + ], "short"); + const weekdayStrings = this.getDisplayNames( + locale, [ + "dates/gregorian/weekdays/sunday", + "dates/gregorian/weekdays/monday", + "dates/gregorian/weekdays/tuesday", + "dates/gregorian/weekdays/wednesday", + "dates/gregorian/weekdays/thursday", + "dates/gregorian/weekdays/friday", + "dates/gregorian/weekdays/saturday", + ], "short"); + + this.postMessageToPicker({ + name: "PickerInit", + detail: { + year, + // Month value from input box starts from 1 instead of 0 + month: month == undefined ? undefined : month - 1, + day, + firstDayOfWeek, + weekends, + monthStrings, + weekdayStrings, + locale, + min: detail.min, + max: detail.max, + step: detail.step, + stepBase: detail.stepBase, + } + }); + break; + } } ]]></body> </method> @@ -109,6 +197,10 @@ } break; } + case "date": { + this.sendPickerValueChanged(this.pickerState); + break; + } } ]]></body> </method> @@ -125,9 +217,60 @@ })); break; } + case "date": { + this.dispatchEvent(new CustomEvent("DateTimePickerValueChanged", { + detail: { + year: value.year, + // Month value from input box starts from 1 instead of 0 + month: value.month == undefined ? undefined : value.month + 1, + day: value.day + } + })); + break; + } } ]]></body> </method> + <method name="getCalendarInfo"> + <parameter name="locale"/> + <body><![CDATA[ + const calendarInfo = this.l10n.getCalendarInfo(locale); + + // Day of week from calendarInfo starts from 1 as Sunday to 7 as Saturday, + // so they need to be mapped to JavaScript convention with 0 as Sunday + // and 6 as Saturday + let firstDayOfWeek = calendarInfo.firstDayOfWeek - 1, + weekendStart = calendarInfo.weekendStart - 1, + weekendEnd = calendarInfo.weekendEnd - 1; + + let weekends = []; + + // Make sure weekendEnd is greater than weekendStart + if (weekendEnd < weekendStart) { + weekendEnd += 7; + } + + // We get the weekends by incrementing weekendStart up to weekendEnd. + // If the start and end is the same day, then weekends only has one day. + for (let day = weekendStart; day <= weekendEnd; day++) { + weekends.push(day % 7); + } + + return { + firstDayOfWeek, + weekends + } + ]]></body> + </method> + <method name="getDisplayNames"> + <parameter name="locale"/> + <parameter name="keys"/> + <parameter name="style"/> + <body><![CDATA[ + const displayNames = this.l10n.getDisplayNames(locale, {keys, style}); + return keys.map(key => displayNames.values[key]); + ]]></body> + </method> <method name="handleEvent"> <parameter name="aEvent"/> <body><![CDATA[ @@ -152,11 +295,16 @@ } switch (aEvent.data.name) { - case "TimePickerPopupChanged": { + case "PickerPopupChanged": { this.pickerState = aEvent.data.detail; this.setInputBoxValue(); break; } + case "ClosePopup": { + this.hidePopup(); + this.closePicker(); + break; + } } ]]></body> </method> @@ -170,12 +318,5 @@ </method> </implementation> - <handlers> - <handler event="popuphiding"> - <![CDATA[ - this.closePicker(); - ]]> - </handler> - </handlers> </binding> </bindings> diff --git a/toolkit/content/widgets/spinner.js b/toolkit/content/widgets/spinner.js index 208ab1931..4901320b5 100644 --- a/toolkit/content/widgets/spinner.js +++ b/toolkit/content/widgets/spinner.js @@ -98,7 +98,7 @@ function Spinner(props, context) { setState(newState) { const { spinner } = this.elements; const { value, items } = this.state; - const { value: newValue, items: newItems, isValueSet, isInvalid } = newState; + const { value: newValue, items: newItems, isValueSet, isInvalid, smoothScroll = true } = newState; if (this._isArrayDiff(newItems, items)) { this.state = Object.assign(this.state, newState); @@ -106,23 +106,23 @@ function Spinner(props, context) { this._scrollTo(newValue, true); } else if (newValue != value) { this.state = Object.assign(this.state, newState); - this._smoothScrollTo(newValue); - } - - if (isValueSet) { - if (isInvalid) { - this._removeSelection(); + if (smoothScroll) { + this._smoothScrollTo(newValue, true); } else { - this._updateSelection(); + this._scrollTo(newValue, true); } } + + if (isValueSet && !isInvalid) { + this._updateSelection(); + } else { + this._removeSelection(); + } }, /** * Whenever scroll event is detected: * - Update the index state - * - If a smooth scroll has reached its destination, set [isScrolling] state - * to false * - If the value has changed, update the [value] state and call [setValue] * - If infinite scrolling is on, reset the scrolling position if necessary */ @@ -135,14 +135,8 @@ function Spinner(props, context) { const value = itemsView[this.state.index + viewportTopOffset].value; - // Check if smooth scrolling has reached its destination. - // This prevents input box jump when input box changes values. - if (this.state.value == value && this.state.isScrolling) { - this.state.isScrolling = false; - } - - // Call setValue if value has changed, and is not smooth scrolling - if (this.state.value != value && !this.state.isScrolling) { + // Call setValue if value has changed + if (this.state.value != value) { this.state.value = value; this.props.setValue(value); } @@ -266,11 +260,11 @@ function Spinner(props, context) { * Attach event listeners to the spinner and buttons. */ _attachEventListeners() { - const { spinner } = this.elements; + const { spinner, container } = this.elements; spinner.addEventListener("scroll", this, { passive: true }); - document.addEventListener("mouseup", this, { passive: true }); - document.addEventListener("mousedown", this); + container.addEventListener("mouseup", this, { passive: true }); + container.addEventListener("mousedown", this, { passive: true }); }, /** @@ -288,9 +282,6 @@ function Spinner(props, context) { break; } case "mousedown": { - // Use preventDefault to keep focus on input boxes - event.preventDefault(); - event.target.setCapture(); this.state.mouseState = { down: true, layerX: event.layerX, @@ -300,11 +291,11 @@ function Spinner(props, context) { // An "active" class is needed to simulate :active pseudo-class // because element is not focused. event.target.classList.add("active"); - this._smoothScrollToIndex(index + 1); + this._smoothScrollToIndex(index - 1); } if (event.target == down) { event.target.classList.add("active"); - this._smoothScrollToIndex(index - 1); + this._smoothScrollToIndex(index + 1); } if (event.target.parentNode == spinner) { // Listen to dragging events @@ -444,10 +435,6 @@ function Spinner(props, context) { _smoothScrollToIndex(index) { const element = this.elements.spinner.children[index]; if (element) { - // Set the isScrolling flag before smooth scrolling begins - // and remove it when it has reached the destination. - // This prevents input box jump when input box changes values - this.state.isScrolling = true; element.scrollIntoView({ behavior: "smooth", block: "start" }); diff --git a/toolkit/content/widgets/timekeeper.js b/toolkit/content/widgets/timekeeper.js index 2234c9e50..3b4e7eb0a 100644 --- a/toolkit/content/widgets/timekeeper.js +++ b/toolkit/content/widgets/timekeeper.js @@ -14,7 +14,7 @@ * { * {Date} min * {Date} max - * {Number} stepInMs + * {Number} step * {String} format: Either "12" or "24" * } */ @@ -286,15 +286,15 @@ function TimeKeeper(props) { * } */ _getSteps(startValue, endValue, minStep, formatter) { - const { min, max, stepInMs } = this.props; + const { min, max, step } = this.props; // The timeStep should be big enough so that there won't be // duplications. Ex: minimum step for minute should be 60000ms, // if smaller than that, next step might return the same minute. - const timeStep = Math.max(minStep, stepInMs); + const timeStep = Math.max(minStep, step); // Make sure the starting point and end point is not off step let time = min.valueOf() + Math.ceil((startValue - min.valueOf()) / timeStep) * timeStep; - let maxValue = min.valueOf() + Math.floor((max.valueOf() - min.valueOf()) / stepInMs) * stepInMs; + let maxValue = min.valueOf() + Math.floor((max.valueOf() - min.valueOf()) / step) * step; let steps = []; // Increment by timeStep until reaching the end of the range. @@ -410,9 +410,9 @@ function TimeKeeper(props) { * @return {Boolean} */ _isOffStep(time) { - const { min, stepInMs } = this.props; + const { min, step } = this.props; - return (time.valueOf() - min.valueOf()) % stepInMs != 0; + return (time.valueOf() - min.valueOf()) % step != 0; } }; } diff --git a/toolkit/content/widgets/timepicker.js b/toolkit/content/widgets/timepicker.js index f438e9ec6..1f0463fe4 100644 --- a/toolkit/content/widgets/timepicker.js +++ b/toolkit/content/widgets/timepicker.js @@ -13,8 +13,6 @@ function TimePicker(context) { const debug = 0 ? console.log.bind(console, "[timepicker]") : function() {}; const DAY_PERIOD_IN_HOURS = 12, - SECOND_IN_MS = 1000, - MINUTE_IN_MS = 60000, DAY_IN_MS = 86400000; TimePicker.prototype = { @@ -24,9 +22,9 @@ function TimePicker(context) { * { * {Number} hour [optional]: Hour in 24 hours format (0~23), default is current hour * {Number} minute [optional]: Minute (0~59), default is current minute - * {String} min [optional]: Minimum time, in 24 hours format. ex: "05:45" - * {String} max [optional]: Maximum time, in 24 hours format. ex: "23:00" - * {Number} step [optional]: Step size in minutes. Default is 60. + * {Number} min: Minimum time, in ms + * {Number} max: Maximum time, in ms + * {Number} step: Step size in ms * {String} format [optional]: "12" for 12 hours, "24" for 24 hours format * {String} locale [optional]: User preferred locale * } @@ -51,11 +49,10 @@ function TimePicker(context) { let timerHour = hour == undefined ? now.getHours() : hour; let timerMinute = minute == undefined ? now.getMinutes() : minute; - // The spec defines 1 step == 1 second, need to convert to ms for timekeeper let timeKeeper = new TimeKeeper({ - min: this._parseTimeString(min) || new Date(0), - max: this._parseTimeString(max) || new Date(DAY_IN_MS - 1), - stepInMs: step ? step * SECOND_IN_MS : MINUTE_IN_MS, + min: new Date(Number.isNaN(min) ? 0 : min), + max: new Date(Number.isNaN(max) ? DAY_IN_MS - 1 : max), + step, format: format || "12" }); timeKeeper.setState({ hour: timerHour, minute: timerMinute }); @@ -64,17 +61,6 @@ function TimePicker(context) { }, /** - * Convert a time string from DOM attribute to a date object. - * - * @param {String} timeString: (ex. "10:30", "23:55", "12:34:56.789") - * @return {Date/Boolean} Date object or false if date is invalid. - */ - _parseTimeString(timeString) { - let time = new Date("1970-01-01T" + timeString + "Z"); - return time.toString() == "Invalid Date" ? false : time; - }, - - /** * Initalize the spinner components. */ _createComponents() { @@ -206,7 +192,7 @@ function TimePicker(context) { // The panel is listening to window for postMessage event, so we // do postMessage to itself to send data to input boxes. window.postMessage({ - name: "TimePickerPopupChanged", + name: "PickerPopupChanged", detail: { hour, minute, @@ -218,6 +204,7 @@ function TimePicker(context) { }, _attachEventListeners() { window.addEventListener("message", this); + document.addEventListener("mousedown", this); }, /** @@ -231,6 +218,12 @@ function TimePicker(context) { this.handleMessage(event); break; } + case "mousedown": { + // Use preventDefault to keep focus on input boxes + event.preventDefault(); + event.target.setCapture(); + break; + } } }, @@ -241,11 +234,11 @@ function TimePicker(context) { */ handleMessage(event) { switch (event.data.name) { - case "TimePickerSetValue": { + case "PickerSetValue": { this.set(event.data.detail); break; } - case "TimePickerInit": { + case "PickerInit": { this.init(event.data.detail); break; } diff --git a/toolkit/content/widgets/toolbar.xml b/toolkit/content/widgets/toolbar.xml index 548504e24..e1f58f7aa 100644 --- a/toolkit/content/widgets/toolbar.xml +++ b/toolkit/content/widgets/toolbar.xml @@ -30,7 +30,7 @@ </field> <field name="externalToolbars"> - [] + [] </field> <!-- Set by customizeToolbar.js --> @@ -49,18 +49,54 @@ <constructor> <![CDATA[ + this.toolbarInfoSeparators = ["|", "-"]; + this.toolbarInfoLegacySeparator = ":"; // Look to see if there is a toolbarset. this.toolbarset = this.firstChild; - while (this.toolbarset && this.toolbarset.localName != "toolbarset") + while (this.toolbarset && this.toolbarset.localName != "toolbarset") { this.toolbarset = toolbarset.nextSibling; + } if (this.toolbarset) { // Create each toolbar described by the toolbarset. var index = 0; - while (toolbarset.hasAttribute("toolbar"+(++index))) { - var toolbarInfo = toolbarset.getAttribute("toolbar"+index); - var infoSplit = toolbarInfo.split(":"); - this.appendCustomToolbar(infoSplit[0], infoSplit[1]); + while (this.toolbarset.hasAttribute("toolbar" + (++index))) { + let hiddingAttribute = + this.toolbarset.getAttribute("type") == "menubar" + ? "autohide" : "collapsed"; + let toolbarInfo = this.toolbarset.getAttribute("toolbar" + index); + let infoSplit = toolbarInfo.split(this.toolbarInfoSeparators[0]); + if (infoSplit.length == 1) { + infoSplit = toolbarInfo.split(this.toolbarInfoLegacySeparator); + } + let infoName = infoSplit[0]; + let infoHidingAttribute = [null, null]; + let infoCurrentSet = ""; + let infoSplitLen = infoSplit.length; + switch (infoSplitLen) { + case 3: + // Pale Moon 27.2+ + // Basilisk (UXP) + infoHidingAttribute = infoSplit[1] + .split(this.toolbarInfoSeparators[1]); + infoCurrentSet = infoSplit[2]; + break; + case 2: + // Legacy: + // - toolbars from Pale Moon 27.0 - 27.1.x + // - Basilisk (moebius) + // The previous value (hiddingAttribute) isn't stored. + infoHidingAttribute = [hiddingAttribute, "false"]; + infoCurrentSet = infoSplit[1]; + break; + default: + Components.utils.reportError( + "Customizable toolbars - an invalid value:" + "\n" + + '"toolbar' + index + '" = "' + toolbarInfo + '"'); + break; + } + this.appendCustomToolbar( + infoName, infoCurrentSet, infoHidingAttribute); } } ]]> @@ -69,6 +105,7 @@ <method name="appendCustomToolbar"> <parameter name="aName"/> <parameter name="aCurrentSet"/> + <parameter name="aHidingAttribute"/> <body> <![CDATA[ if (!this.toolbarset) @@ -84,6 +121,10 @@ toolbar.setAttribute("iconsize", this.getAttribute("iconsize")); toolbar.setAttribute("context", this.toolbarset.getAttribute("context")); toolbar.setAttribute("class", "chromeclass-toolbar"); + // Restore persist the hiding attribute. + if (aHidingAttribute[0]) { + toolbar.setAttribute(aHidingAttribute[0], aHidingAttribute[1]); + } this.insertBefore(toolbar, this.toolbarset); return toolbar; diff --git a/toolkit/locales/en-US/chrome/global/autocomplete.properties b/toolkit/locales/en-US/chrome/global/autocomplete.properties index 5c7c84b96..44da643ac 100644 --- a/toolkit/locales/en-US/chrome/global/autocomplete.properties +++ b/toolkit/locales/en-US/chrome/global/autocomplete.properties @@ -1,6 +1,11 @@ # 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/. +switchToTab = Switch to tab + +# LOCALIZATION NOTE (visitURL): +# %S is the URL to visit. +visitURL = Visit %S # LOCALIZATION NOTE (searchWithEngine): %S will be replaced with # the search engine provider's name. This format was chosen because diff --git a/toolkit/locales/en-US/chrome/global/customizeToolbar.properties b/toolkit/locales/en-US/chrome/global/customizeToolbar.properties index 0ec6d2c1d..b19152fab 100644 --- a/toolkit/locales/en-US/chrome/global/customizeToolbar.properties +++ b/toolkit/locales/en-US/chrome/global/customizeToolbar.properties @@ -5,6 +5,7 @@ enterToolbarTitle=New Toolbar enterToolbarName=Enter a name for this toolbar: enterToolbarDup=There is already a toolbar with the name “%S”. Please enter a different name. +enterToolbarIllegalChars=The name contains illegal character "|". Please enter a different name. enterToolbarBlank=You must enter a name to create a new toolbar. separatorTitle=Separator springTitle=Flexible Space diff --git a/toolkit/locales/en-US/chrome/global/datetimebox.dtd b/toolkit/locales/en-US/chrome/global/datetimebox.dtd new file mode 100644 index 000000000..0deffa6b3 --- /dev/null +++ b/toolkit/locales/en-US/chrome/global/datetimebox.dtd @@ -0,0 +1,9 @@ +<!-- 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/. --> + +<!-- Placeholders for input type=date --> + +<!ENTITY date.year.placeholder "yyyy"> +<!ENTITY date.month.placeholder "mm"> +<!ENTITY date.day.placeholder "dd"> diff --git a/toolkit/locales/jar.mn b/toolkit/locales/jar.mn index e49e978f5..abc96086f 100644 --- a/toolkit/locales/jar.mn +++ b/toolkit/locales/jar.mn @@ -39,6 +39,7 @@ locale/@AB_CD@/global/customizeToolbar.dtd (%chrome/global/customizeToolbar.dtd) locale/@AB_CD@/global/customizeToolbar.properties (%chrome/global/customizeToolbar.properties) #endif + locale/@AB_CD@/global/datetimebox.dtd (%chrome/global/datetimebox.dtd) locale/@AB_CD@/global/datetimepicker.dtd (%chrome/global/datetimepicker.dtd) locale/@AB_CD@/global/dateFormat.properties (%chrome/global/dateFormat.properties) locale/@AB_CD@/global/dialogOverlay.dtd (%chrome/global/dialogOverlay.dtd) diff --git a/toolkit/modules/DateTimePickerHelper.jsm b/toolkit/modules/DateTimePickerHelper.jsm index 398687988..b509742b0 100644 --- a/toolkit/modules/DateTimePickerHelper.jsm +++ b/toolkit/modules/DateTimePickerHelper.jsm @@ -21,6 +21,7 @@ this.EXPORTED_SYMBOLS = [ Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); /* * DateTimePickerHelper receives message from content side (input box) and @@ -63,9 +64,13 @@ this.DateTimePickerHelper = { return; } this.picker.closePicker(); + this.close(); break; } case "FormDateTime:UpdatePicker": { + if (!this.picker) { + return; + } this.picker.setPopupValue(aMessage.data); break; } @@ -87,6 +92,7 @@ this.DateTimePickerHelper = { if (browser) { browser.messageManager.sendAsyncMessage("FormDateTime:PickerClosed"); } + this.picker.closePicker(); this.close(); break; } @@ -97,18 +103,15 @@ this.DateTimePickerHelper = { // Called when picker value has changed, notify input box about it. updateInputBoxValue: function(aEvent) { - // TODO: parse data based on input type. - const { hour, minute } = aEvent.detail; - debug("hour: " + hour + ", minute: " + minute); let browser = this.weakBrowser ? this.weakBrowser.get() : null; if (browser) { browser.messageManager.sendAsyncMessage( - "FormDateTime:PickerValueChanged", { hour, minute }); + "FormDateTime:PickerValueChanged", aEvent.detail); } }, // Get picker from browser and show it anchored to the input box. - showPicker: function(aBrowser, aData) { + showPicker: Task.async(function* (aBrowser, aData) { let rect = aData.rect; let dir = aData.dir; let type = aData.type; @@ -138,13 +141,23 @@ this.DateTimePickerHelper = { debug("aBrowser.dateTimePicker not found, exiting now."); return; } - this.picker.loadPicker(type, detail); + // The datetimepopup binding is only attached when it is needed. + // Check if openPicker method is present to determine if binding has + // been attached. If not, attach the binding first before calling it. + if (!this.picker.openPicker) { + let bindingPromise = new Promise(resolve => { + this.picker.addEventListener("DateTimePickerBindingReady", + resolve, {once: true}); + }); + this.picker.setAttribute("active", true); + yield bindingPromise; + } // The arrow panel needs an anchor to work. The popupAnchor (this._anchor) // is a transparent div that the arrow can point to. - this.picker.openPopup(this._anchor, "after_start", rect.left, rect.top); + this.picker.openPicker(type, this._anchor, detail); this.addPickerListeners(); - }, + }), // Picker is closed, do some cleanup. close: function() { diff --git a/toolkit/mozapps/extensions/AddonManager.jsm b/toolkit/mozapps/extensions/AddonManager.jsm index 681c4240a..3913c2088 100644 --- a/toolkit/mozapps/extensions/AddonManager.jsm +++ b/toolkit/mozapps/extensions/AddonManager.jsm @@ -88,9 +88,9 @@ Cu.import("resource://gre/modules/Log.jsm"); // Configure a logger at the parent 'addons' level to format // messages for all the modules under addons.* const PARENT_LOGGER_ID = "addons"; -let parentLogger = Log.repository.getLogger(PARENT_LOGGER_ID); +var parentLogger = Log.repository.getLogger(PARENT_LOGGER_ID); parentLogger.level = Log.Level.Warn; -let formatter = new Log.BasicFormatter(); +var formatter = new Log.BasicFormatter(); // Set parent logger (and its children) to append to // the Javascript section of the Browser Console parentLogger.addAppender(new Log.ConsoleAppender(formatter)); @@ -101,7 +101,7 @@ parentLogger.addAppender(new Log.DumpAppender(formatter)); // Create a new logger (child of 'addons' logger) // for use by the Addons Manager const LOGGER_ID = "addons.manager"; -let logger = Log.repository.getLogger(LOGGER_ID); +var logger = Log.repository.getLogger(LOGGER_ID); // Provide the ability to enable/disable logging // messages at runtime. diff --git a/toolkit/mozapps/extensions/DeferredSave.jsm b/toolkit/mozapps/extensions/DeferredSave.jsm index d7f5b8864..7587ce83b 100644 --- a/toolkit/mozapps/extensions/DeferredSave.jsm +++ b/toolkit/mozapps/extensions/DeferredSave.jsm @@ -12,7 +12,7 @@ Cu.import("resource://gre/modules/osfile.jsm"); Cu.import("resource://gre/modules/Promise.jsm"); // Make it possible to mock out timers for testing -let MakeTimer = () => Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); +var MakeTimer = () => Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); this.EXPORTED_SYMBOLS = ["DeferredSave"]; @@ -23,9 +23,9 @@ Cu.import("resource://gre/modules/Log.jsm"); //Configure a logger at the parent 'DeferredSave' level to format //messages for all the modules under DeferredSave.* const DEFERREDSAVE_PARENT_LOGGER_ID = "DeferredSave"; -let parentLogger = Log.repository.getLogger(DEFERREDSAVE_PARENT_LOGGER_ID); +var parentLogger = Log.repository.getLogger(DEFERREDSAVE_PARENT_LOGGER_ID); parentLogger.level = Log.Level.Warn; -let formatter = new Log.BasicFormatter(); +var formatter = new Log.BasicFormatter(); //Set parent logger (and its children) to append to //the Javascript section of the Browser Console parentLogger.addAppender(new Log.ConsoleAppender(formatter)); diff --git a/toolkit/mozapps/extensions/addonManager.js b/toolkit/mozapps/extensions/addonManager.js index 862b1ea69..731e70c6c 100644 --- a/toolkit/mozapps/extensions/addonManager.js +++ b/toolkit/mozapps/extensions/addonManager.js @@ -31,9 +31,9 @@ const CHILD_SCRIPT = "resource://gre/modules/addons/Content.js"; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); -let gSingleton = null; +var gSingleton = null; -let gParentMM = null; +var gParentMM = null; function amManager() { diff --git a/toolkit/mozapps/extensions/amInstallTrigger.js b/toolkit/mozapps/extensions/amInstallTrigger.js index b83cbe60b..a18fe84c4 100644 --- a/toolkit/mozapps/extensions/amInstallTrigger.js +++ b/toolkit/mozapps/extensions/amInstallTrigger.js @@ -18,7 +18,7 @@ const MSG_INSTALL_ADDONS = "WebInstallerInstallAddonsFromWebpage"; const MSG_INSTALL_CALLBACK = "WebInstallerInstallCallback"; -let log = Log.repository.getLogger("AddonManager.InstallTrigger"); +var log = Log.repository.getLogger("AddonManager.InstallTrigger"); log.level = Log.Level[Preferences.get("extensions.logging.enabled", false) ? "Warn" : "Trace"]; function CallbackObject(id, callback, urls, mediator) { diff --git a/toolkit/mozapps/extensions/amWebInstallListener.js b/toolkit/mozapps/extensions/amWebInstallListener.js index 901beef07..ac6e2495d 100644 --- a/toolkit/mozapps/extensions/amWebInstallListener.js +++ b/toolkit/mozapps/extensions/amWebInstallListener.js @@ -37,7 +37,7 @@ const LOGGER_ID = "addons.weblistener"; // Create a new logger for use by the Addons Web Listener // (Requires AddonManager.jsm) -let logger = Log.repository.getLogger(LOGGER_ID); +var logger = Log.repository.getLogger(LOGGER_ID); function notifyObservers(aTopic, aBrowser, aUri, aInstalls) { let info = { diff --git a/toolkit/mozapps/extensions/content/extensions.js b/toolkit/mozapps/extensions/content/extensions.js index 6f2a47482..8d9c132e6 100644 --- a/toolkit/mozapps/extensions/content/extensions.js +++ b/toolkit/mozapps/extensions/content/extensions.js @@ -2456,7 +2456,7 @@ var gSearchView = { this._allResultsLink.setAttribute("href", AddonRepository.getSearchURL(this._lastQuery)); this._allResultsLink.hidden = false; - }, + }, updateListAttributes: function gSearchView_updateListAttributes() { var item = this._listBox.querySelector("richlistitem[remote='true'][first]"); diff --git a/toolkit/mozapps/extensions/content/update.js b/toolkit/mozapps/extensions/content/update.js index 3d87f4d4b..afc74dca8 100644 --- a/toolkit/mozapps/extensions/content/update.js +++ b/toolkit/mozapps/extensions/content/update.js @@ -22,7 +22,7 @@ XPCOMUtils.defineLazyModuleGetter(this, "AddonRepository", "resource://gre/modul XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Promise", "resource://gre/modules/Promise.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Log", "resource://gre/modules/Log.jsm"); -let logger = null; +var logger = null; var gUpdateWizard = { // When synchronizing app compatibility info this contains all installed @@ -169,7 +169,7 @@ var gOfflinePage = { } // Addon listener to count addons enabled/disabled by metadata checks -let listener = { +var listener = { onDisabled: function listener_onDisabled(aAddon) { gUpdateWizard.affectedAddonIDs.add(aAddon.id); gUpdateWizard.metadataDisabled++; diff --git a/toolkit/mozapps/extensions/internal/AddonRepository.jsm b/toolkit/mozapps/extensions/internal/AddonRepository.jsm index adcecbee7..76a7528c7 100644 --- a/toolkit/mozapps/extensions/internal/AddonRepository.jsm +++ b/toolkit/mozapps/extensions/internal/AddonRepository.jsm @@ -72,7 +72,7 @@ const LOGGER_ID = "addons.repository"; // Create a new logger for use by the Addons Repository // (Requires AddonManager.jsm) -let logger = Log.repository.getLogger(LOGGER_ID); +var logger = Log.repository.getLogger(LOGGER_ID); // A map between XML keys to AddonSearchResult keys for string values // that require no extra parsing from XML @@ -101,7 +101,7 @@ const INTEGER_KEY_MAP = { }; // Wrap the XHR factory so that tests can override with a mock -let XHRequest = Components.Constructor("@mozilla.org/xmlextras/xmlhttprequest;1", +var XHRequest = Components.Constructor("@mozilla.org/xmlextras/xmlhttprequest;1", "nsIXMLHttpRequest"); function convertHTMLToPlainText(html) { diff --git a/toolkit/mozapps/extensions/internal/AddonRepository_SQLiteMigrator.jsm b/toolkit/mozapps/extensions/internal/AddonRepository_SQLiteMigrator.jsm index 128146bbe..11944ddf5 100644 --- a/toolkit/mozapps/extensions/internal/AddonRepository_SQLiteMigrator.jsm +++ b/toolkit/mozapps/extensions/internal/AddonRepository_SQLiteMigrator.jsm @@ -30,7 +30,7 @@ const LOGGER_ID = "addons.repository.sqlmigrator"; // Create a new logger for use by the Addons Repository SQL Migrator // (Requires AddonManager.jsm) -let logger = Log.repository.getLogger(LOGGER_ID); +var logger = Log.repository.getLogger(LOGGER_ID); this.EXPORTED_SYMBOLS = ["AddonRepository_SQLiteMigrator"]; diff --git a/toolkit/mozapps/extensions/internal/AddonUpdateChecker.jsm b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.jsm index d68a0f175..939e2e269 100644 --- a/toolkit/mozapps/extensions/internal/AddonUpdateChecker.jsm +++ b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.jsm @@ -52,7 +52,7 @@ const LOGGER_ID = "addons.update-checker"; // Create a new logger for use by the Addons Update Checker // (Requires AddonManager.jsm) -let logger = Log.repository.getLogger(LOGGER_ID); +var logger = Log.repository.getLogger(LOGGER_ID); /** * A serialisation method for RDF data that produces an identical string diff --git a/toolkit/mozapps/extensions/internal/Content.js b/toolkit/mozapps/extensions/internal/Content.js index 29c0ed8ce..61a8b0323 100644 --- a/toolkit/mozapps/extensions/internal/Content.js +++ b/toolkit/mozapps/extensions/internal/Content.js @@ -8,9 +8,9 @@ const {classes: Cc, interfaces: Ci, utils: Cu} = Components; -let {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); +var {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); -let nsIFile = Components.Constructor("@mozilla.org/file/local;1", "nsIFile", +var nsIFile = Components.Constructor("@mozilla.org/file/local;1", "nsIFile", "initWithPath"); const MSG_JAR_FLUSH = "AddonJarFlush"; diff --git a/toolkit/mozapps/extensions/internal/GMPProvider.jsm b/toolkit/mozapps/extensions/internal/GMPProvider.jsm index a55457f6e..52affa9ba 100644 --- a/toolkit/mozapps/extensions/internal/GMPProvider.jsm +++ b/toolkit/mozapps/extensions/internal/GMPProvider.jsm @@ -64,11 +64,11 @@ XPCOMUtils.defineLazyGetter(this, "pluginsBundle", XPCOMUtils.defineLazyGetter(this, "gmpService", () => Cc["@mozilla.org/gecko-media-plugin-service;1"].getService(Ci.mozIGeckoMediaPluginChromeService)); -let messageManager = Cc["@mozilla.org/globalmessagemanager;1"] +var messageManager = Cc["@mozilla.org/globalmessagemanager;1"] .getService(Ci.nsIMessageListenerManager); -let gLogger; -let gLogAppenderDump = null; +var gLogger; +var gLogAppenderDump = null; function configureLogging() { if (!gLogger) { @@ -443,7 +443,7 @@ GMPWrapper.prototype = { }, }; -let GMPProvider = { +var GMPProvider = { get name() { return "GMPProvider"; }, _plugins: null, diff --git a/toolkit/mozapps/extensions/internal/LightweightThemeImageOptimizer.jsm b/toolkit/mozapps/extensions/internal/LightweightThemeImageOptimizer.jsm index fccde9a81..1e7d6b0d8 100644 --- a/toolkit/mozapps/extensions/internal/LightweightThemeImageOptimizer.jsm +++ b/toolkit/mozapps/extensions/internal/LightweightThemeImageOptimizer.jsm @@ -49,7 +49,7 @@ this.LightweightThemeImageOptimizer = { Object.freeze(LightweightThemeImageOptimizer); -let ImageCropper = { +var ImageCropper = { _inProgress: {}, getCroppedImageURL: @@ -119,7 +119,7 @@ let ImageCropper = { } }; -let ImageFile = { +var ImageFile = { read: function ImageFile_read(aURI, aCallback) { this._netUtil.asyncFetch2( aURI, @@ -158,7 +158,7 @@ let ImageFile = { XPCOMUtils.defineLazyModuleGetter(ImageFile, "_netUtil", "resource://gre/modules/NetUtil.jsm", "NetUtil"); -let ImageTools = { +var ImageTools = { decode: function ImageTools_decode(aInputStream, aContentType) { let outParam = {value: null}; @@ -187,7 +187,7 @@ let ImageTools = { XPCOMUtils.defineLazyServiceGetter(ImageTools, "_imgTools", "@mozilla.org/image/tools;1", "imgITools"); -let Utils = { +var Utils = { createCopy: function Utils_createCopy(aData) { let copy = {}; for (let [k, v] in Iterator(aData)) { diff --git a/toolkit/mozapps/extensions/internal/PluginProvider.jsm b/toolkit/mozapps/extensions/internal/PluginProvider.jsm index 04a4f9d7c..cb07dcb12 100644 --- a/toolkit/mozapps/extensions/internal/PluginProvider.jsm +++ b/toolkit/mozapps/extensions/internal/PluginProvider.jsm @@ -23,7 +23,7 @@ const LOGGER_ID = "addons.plugins"; // Create a new logger for use by the Addons Plugin Provider // (Requires AddonManager.jsm) -let logger = Log.repository.getLogger(LOGGER_ID); +var logger = Log.repository.getLogger(LOGGER_ID); function getIDHashForString(aStr) { // return the two-digit hexadecimal code for a byte diff --git a/toolkit/mozapps/extensions/internal/XPIProvider.jsm b/toolkit/mozapps/extensions/internal/XPIProvider.jsm index d5f1ab5dd..2c5e3dfa7 100644 --- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm +++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm @@ -129,7 +129,7 @@ const PREFIX_NS_EM = "http://www.mozilla.org/2004/em-rdf#"; const TOOLKIT_ID = "toolkit@mozilla.org"; #ifdef MOZ_PHOENIX_EXTENSIONS const FIREFOX_ID = "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}" -const FIREFOX_APPCOMPATVERSION = "27.9" +const FIREFOX_APPCOMPATVERSION = "56.9" #endif // The value for this is in Makefile.in @@ -222,7 +222,7 @@ const LOGGER_ID = "addons.xpi"; // Create a new logger for use by all objects in this Addons XPI Provider module // (Requires AddonManager.jsm) -let logger = Log.repository.getLogger(LOGGER_ID); +var logger = Log.repository.getLogger(LOGGER_ID); const LAZY_OBJECTS = ["XPIDatabase"]; @@ -7822,7 +7822,7 @@ WinRegInstallLocation.prototype = { }; #endif -let addonTypes = [ +var addonTypes = [ new AddonManagerPrivate.AddonType("extension", URI_EXTENSION_STRINGS, STRING_TYPE_NAME, AddonManager.VIEW_TYPE_LIST, 4000, diff --git a/toolkit/mozapps/extensions/internal/XPIProviderUtils.js b/toolkit/mozapps/extensions/internal/XPIProviderUtils.js index 2cef907f1..d26029455 100644 --- a/toolkit/mozapps/extensions/internal/XPIProviderUtils.js +++ b/toolkit/mozapps/extensions/internal/XPIProviderUtils.js @@ -29,7 +29,7 @@ const LOGGER_ID = "addons.xpi-utils"; // Create a new logger for use by the Addons XPI Provider Utils // (Requires AddonManager.jsm) -let logger = Log.repository.getLogger(LOGGER_ID); +var logger = Log.repository.getLogger(LOGGER_ID); const KEY_PROFILEDIR = "ProfD"; const FILE_DATABASE = "extensions.sqlite"; diff --git a/toolkit/mozapps/extensions/test/browser/browser-common.ini b/toolkit/mozapps/extensions/test/browser/browser-common.ini index eaab29f75..3e88833ef 100644 --- a/toolkit/mozapps/extensions/test/browser/browser-common.ini +++ b/toolkit/mozapps/extensions/test/browser/browser-common.ini @@ -39,8 +39,6 @@ skip-if = true # Bug 1093190 - Disabled due to leak [browser_discovery.js] skip-if = e10s # Bug ?????? - test times out on try on all platforms, but works locally for markh! [browser_dragdrop.js] -skip-if = buildapp == 'mulet' -[browser_experiments.js] skip-if = e10s [browser_list.js] [browser_metadataTimeout.js] diff --git a/toolkit/mozapps/installer/find-dupes.py b/toolkit/mozapps/installer/find-dupes.py index bd0561c97..34ef675f4 100644 --- a/toolkit/mozapps/installer/find-dupes.py +++ b/toolkit/mozapps/installer/find-dupes.py @@ -4,15 +4,8 @@ import sys import hashlib -import re -from mozbuild.preprocessor import Preprocessor -from mozbuild.util import DefinesAction from mozpack.packager.unpack import UnpackFinder -from mozpack.files import DeflatedFile from collections import OrderedDict -from StringIO import StringIO -import argparse -import buildconfig ''' Find files duplicated in a given packaged directory, independently of its @@ -20,116 +13,36 @@ package format. ''' -def normalize_osx_path(p): - ''' - Strips the first 3 elements of an OSX app path - - >>> normalize_osx_path('Nightly.app/foo/bar/baz') - 'baz' - ''' - bits = p.split('/') - if len(bits) > 3 and bits[0].endswith('.app'): - return '/'.join(bits[3:]) - return p - - -def normalize_l10n_path(p): - ''' - Normalizes localized paths to en-US - - >>> normalize_l10n_path('chrome/es-ES/locale/branding/brand.properties') - 'chrome/en-US/locale/branding/brand.properties' - >>> normalize_l10n_path('chrome/fr/locale/fr/browser/aboutHome.dtd') - 'chrome/en-US/locale/en-US/browser/aboutHome.dtd' - ''' - # Keep a trailing slash here! e.g. locales like 'br' can transform - # 'chrome/br/locale/branding/' into 'chrome/en-US/locale/en-USanding/' - p = re.sub(r'chrome/(\S+)/locale/\1/', - 'chrome/en-US/locale/en-US/', - p) - p = re.sub(r'chrome/(\S+)/locale/', - 'chrome/en-US/locale/', - p) - return p - - -def normalize_path(p): - return normalize_osx_path(normalize_l10n_path(p)) - - -def find_dupes(source, allowed_dupes, bail=True): - allowed_dupes = set(allowed_dupes) +def find_dupes(source): md5s = OrderedDict() for p, f in UnpackFinder(source): content = f.open().read() m = hashlib.md5(content).digest() - if m not in md5s: - if isinstance(f, DeflatedFile): - compressed = f.file.compressed_size - else: - compressed = len(content) - md5s[m] = (len(content), compressed, []) - md5s[m][2].append(p) + if not m in md5s: + md5s[m] = (len(content), []) + md5s[m][1].append(p) total = 0 - total_compressed = 0 num_dupes = 0 - unexpected_dupes = [] - for m, (size, compressed, paths) in sorted(md5s.iteritems(), - key=lambda x: x[1][1]): + for m, (size, paths) in md5s.iteritems(): if len(paths) > 1: - print 'Duplicates %d bytes%s%s:' % (size, - ' (%d compressed)' % compressed if compressed != size else '', + print 'Duplicates %d bytes%s:' % (size, ' (%d times)' % (len(paths) - 1) if len(paths) > 2 else '') print ''.join(' %s\n' % p for p in paths) total += (len(paths) - 1) * size - total_compressed += (len(paths) - 1) * compressed num_dupes += 1 - - unexpected_dupes.extend([p for p in paths if normalize_path(p) not in allowed_dupes]) - if num_dupes: - print "WARNING: Found %d duplicated files taking %d bytes (%s)" % \ - (num_dupes, total, - '%d compressed' % total_compressed if total_compressed != total - else 'uncompressed') - - if unexpected_dupes: - errortype = "ERROR" if bail else "WARNING" - print "%s: The following duplicated files are not allowed:" % errortype - print "\n".join(unexpected_dupes) - if bail: - sys.exit(1) + print "WARNING: Found %d duplicated files taking %d bytes" % \ + (num_dupes, total) + " (uncompressed)" def main(): - parser = argparse.ArgumentParser(description='Find duplicate files in directory.') - parser.add_argument('--warning', '-w', action='store_true', - help='Only warn about duplicates, do not exit with an error') - parser.add_argument('--file', '-f', action='append', dest='dupes_files', default=[], - help='Add exceptions to the duplicate list from this file') - parser.add_argument('-D', action=DefinesAction) - parser.add_argument('-U', action='append', default=[]) - parser.add_argument('directory', - help='The directory to check for duplicates in') - - args = parser.parse_args() - - allowed_dupes = [] - for filename in args.dupes_files: - pp = Preprocessor() - pp.context.update(buildconfig.defines) - if args.D: - pp.context.update(args.D) - for undefine in args.U: - if undefine in pp.context: - del pp.context[undefine] - pp.out = StringIO() - pp.do_filter('substitution') - pp.do_include(filename) - allowed_dupes.extend([line.partition('#')[0].rstrip() - for line in pp.out.getvalue().splitlines()]) + if len(sys.argv) != 2: + import os + print >>sys.stderr, "Usage: %s directory" % \ + os.path.basename(sys.argv[0]) + sys.exit(1) - find_dupes(args.directory, bail=not args.warning, allowed_dupes=allowed_dupes) + find_dupes(sys.argv[1]) if __name__ == "__main__": main() diff --git a/toolkit/mozapps/installer/packager.mk b/toolkit/mozapps/installer/packager.mk index 68247e7df..71a956aa4 100644 --- a/toolkit/mozapps/installer/packager.mk +++ b/toolkit/mozapps/installer/packager.mk @@ -54,7 +54,7 @@ stage-package: $(MOZ_PKG_MANIFEST) $(MOZ_PKG_MANIFEST_DEPS) $(addprefix --unify ,$(UNIFY_DIST)) \ $(MOZ_PKG_MANIFEST) $(DIST) $(DIST)/$(STAGEPATH)$(MOZ_PKG_DIR)$(if $(MOZ_PKG_MANIFEST),,$(_BINPATH)) \ $(if $(filter omni,$(MOZ_PACKAGER_FORMAT)),$(if $(NON_OMNIJAR_FILES),--non-resource $(NON_OMNIJAR_FILES))) - $(PYTHON) $(MOZILLA_DIR)/toolkit/mozapps/installer/find-dupes.py $(DEFINES) $(ACDEFINES) $(MOZ_PKG_DUPEFLAGS) $(DIST)/$(STAGEPATH)$(MOZ_PKG_DIR) + $(PYTHON) $(MOZILLA_DIR)/toolkit/mozapps/installer/find-dupes.py $(DIST)/$(STAGEPATH)$(MOZ_PKG_DIR) ifdef MOZ_PACKAGE_JSSHELL # Package JavaScript Shell @echo 'Packaging JavaScript Shell...' diff --git a/toolkit/mozapps/webextensions/test/browser/browser-common.ini b/toolkit/mozapps/webextensions/test/browser/browser-common.ini index eda266e2f..83920465b 100644 --- a/toolkit/mozapps/webextensions/test/browser/browser-common.ini +++ b/toolkit/mozapps/webextensions/test/browser/browser-common.ini @@ -34,7 +34,6 @@ skip-if = true # Bug 1093190 - Disabled due to leak [browser_discovery.js] [browser_dragdrop.js] skip-if = buildapp == 'mulet' -[browser_experiments.js] [browser_list.js] [browser_metadataTimeout.js] [browser_searching.js] diff --git a/toolkit/themes/shared/datetimeinputpickers.css b/toolkit/themes/shared/datetimeinputpickers.css new file mode 100644 index 000000000..f0c4315e5 --- /dev/null +++ b/toolkit/themes/shared/datetimeinputpickers.css @@ -0,0 +1,377 @@ +/* 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/. */ + +:root { + --font-size-default: 1.1rem; + --spinner-width: 3rem; + --spinner-margin-top-bottom: 0.4rem; + --spinner-item-height: 2.4rem; + --spinner-item-margin-bottom: 0.1rem; + --spinner-button-height: 1.2rem; + --colon-width: 2rem; + --day-period-spacing-width: 1rem; + --calendar-width: 23.1rem; + --date-picker-item-height: 2.5rem; + --date-picker-item-width: 3.3rem; + + --border: 0.1rem solid #D6D6D6; + --border-radius: 0.3rem; + --border-active-color: #B1B1B1; + + --font-color: #191919; + --fill-color: #EBEBEB; + + --today-fill-color: rgb(212, 212, 212); + + --selected-font-color: #FFFFFF; + --selected-fill-color: #0996F8; + + --button-font-color: #858585; + --button-font-color-hover: #4D4D4D; + --button-font-color-active: #191919; + --button-fill-color-active: #D4D4D4; + + --weekday-header-font-color: #6C6C6C; + --weekend-header-font-color: rgb(218, 78, 68); + + --weekend-font-color: rgb(218, 78, 68); + --weekday-outside-font-color: rgb(153, 153, 153); + --weekend-outside-font-color: rgb(255, 152, 143); + + --weekday-disabled-font-color: rgba(25, 25, 25, 0.2); + --weekend-disabled-font-color: rgba(218, 78, 68, 0.2); + --disabled-fill-color: rgba(235, 235, 235, 0.8); + + --disabled-opacity: 0.2; +} + +html { + font-size: 10px; +} + +body { + margin: 0; + color: var(--font-color); + font: message-box; + font-size: var(--font-size-default); +} + +button { + -moz-appearance: none; + background: none; + border: none; +} + +.nav { + display: flex; + width: var(--calendar-width); + height: 2.4rem; + margin-bottom: 0.8rem; + justify-content: space-between; +} + +.nav > button { + width: 3rem; + height: var(--date-picker-item-height); + background-color: var(--button-font-color); +} + +.nav > button:hover { + background-color: var(--button-font-color-hover); +} + +.nav > button.active { + background-color: var(--button-font-color-active); +} + +.nav > button.left { + background: url("chrome://global/skin/icons/calendar-arrows.svg#left") no-repeat 50% 50%; +} + +.nav > button.right { + background: url("chrome://global/skin/icons/calendar-arrows.svg#right") no-repeat 50% 50%; +} + +.month-year-container { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + top: 0; + left: 3rem; + width: 17.1rem; + height: var(--date-picker-item-height); + z-index: 10; +} + +button.month-year { + font-size: 1.3rem; + border: var(--border); + border-radius: 0.3rem; + padding: 0.2rem 2.6rem 0.2rem 1.2rem; +} + +button.month-year:hover { + background: var(--fill-color); +} + +button.month-year.active { + border-color: var(--border-active-color); + background: var(--button-fill-color-active); +} + +button.month-year::after { + position: absolute; + content: ""; + width: 2.6rem; + height: 1.6rem; + background: url("chrome://global/skin/icons/spinner-arrows.svg#down") no-repeat 50% 50%; +} + +button.month-year.active::after { + background: url("chrome://global/skin/icons/spinner-arrows.svg#up") no-repeat 50% 50%; +} + +.month-year-view { + position: absolute; + z-index: 5; + padding-top: 3.2rem; + top: 0; + left: 0; + bottom: 0; + width: var(--calendar-width); + background: window; + opacity: 1; + transition: opacity 0.15s; +} + +.month-year-view.hidden { + visibility: hidden; + opacity: 0; +} + +.month-year-view > .spinner-container { + width: 5.5rem; + margin: 0 0.5rem; +} + +.month-year-view .spinner { + transform: scaleY(1); + transform-origin: top; + transition: transform 0.15s; +} + +.month-year-view.hidden .spinner { + transform: scaleY(0); + transition: none; +} + +.month-year-view .spinner > div { + transform: scaleY(1); + transition: transform 0.15s; +} + +.month-year-view.hidden .spinner > div { + transform: scaleY(2.5); + transition: none; +} + +.calendar-container { + cursor: default; + display: flex; + flex-direction: column; + width: var(--calendar-width); +} + +.week-header { + display: flex; +} + +.week-header > div { + color: var(--weekday-header-font-color); +} + +.week-header > div.weekend { + color: var(--weekend-header-font-color); +} + +.days-viewport { + height: 15rem; + overflow: hidden; + position: relative; +} + +.days-view { + position: absolute; + display: flex; + flex-wrap: wrap; + flex-direction: row; +} + +.week-header > div, +.days-view > div { + align-items: center; + display: flex; + height: var(--date-picker-item-height); + position: relative; + justify-content: center; + width: var(--date-picker-item-width); +} + +.days-view > .outside { + color: var(--weekday-outside-font-color); +} + +.days-view > .weekend { + color: var(--weekend-font-color); +} + +.days-view > .weekend.outside { + color: var(--weekend-outside-font-color); +} + +.days-view > .out-of-range, +.days-view > .off-step { + color: var(--weekday-disabled-font-color); + background: var(--disabled-fill-color); +} + +.days-view > .out-of-range.weekend, +.days-view > .off-step.weekend { + color: var(--weekend-disabled-font-color); +} + +.days-view > .today { + font-weight: bold; +} + +.days-view > .out-of-range::before, +.days-view > .off-step::before { + display: none; +} + +.days-view > div:hover::before, +.days-view > .select::before, +.days-view > .today::before { + top: 5%; + bottom: 5%; + left: 5%; + right: 5%; +} + +#time-picker, +.month-year-view { + display: flex; + flex-direction: row; + justify-content: center; +} + +.spinner-container { + display: flex; + flex-direction: column; + width: var(--spinner-width); +} + +.spinner-container > button { + background-color: var(--button-font-color); + height: var(--spinner-button-height); +} + +.spinner-container > button:hover { + background-color: var(--button-font-color-hover); +} + +.spinner-container > button.active { + background-color: var(--button-font-color-active); +} + +.spinner-container > button.up { + background: url("chrome://global/skin/icons/spinner-arrows.svg#up") no-repeat 50% 50%; +} + +.spinner-container > button.down { + background: url("chrome://global/skin/icons/spinner-arrows.svg#down") no-repeat 50% 50%; +} + +.spinner-container.hide-buttons > button { + visibility: hidden; +} + +.spinner-container > .spinner { + position: relative; + width: 100%; + margin: var(--spinner-margin-top-bottom) 0; + cursor: default; + overflow-y: scroll; + scroll-snap-type: mandatory; + scroll-snap-points-y: repeat(100%); +} + +.spinner-container > .spinner > div { + box-sizing: border-box; + position: relative; + text-align: center; + padding: calc((var(--spinner-item-height) - var(--font-size-default)) / 2) 0; + margin-bottom: var(--spinner-item-margin-bottom); + height: var(--spinner-item-height); + -moz-user-select: none; + scroll-snap-coordinate: 0 0; +} + +.spinner-container > .spinner > div::before, +.calendar-container .days-view > div::before { + position: absolute; + top: 5%; + bottom: 5%; + left: 5%; + right: 5%; + z-index: -10; + border-radius: var(--border-radius); +} + +.spinner-container > .spinner > div:hover::before, +.calendar-container .days-view > div:hover::before { + background: var(--fill-color); + border: var(--border); + content: ""; +} + +.calendar-container .days-view > div.today::before { + background: var(--today-fill-color); + content: ""; +} + +.spinner-container > .spinner:not(.scrolling) > div.selection, +.calendar-container .days-view > div.selection { + color: var(--selected-font-color); +} + +.spinner-container > .spinner > div.selection::before, +.calendar-container .days-view > div.selection::before { + background: var(--selected-fill-color); + border: none; + content: ""; +} + +.spinner-container > .spinner > div.disabled::before, +.spinner-container > .spinner.scrolling > div.selection::before, +.spinner-container > .spinner.scrolling > div:hover::before { + display: none; +} + +.spinner-container > .spinner > div.disabled { + opacity: var(--disabled-opacity); +} + +.colon { + display: flex; + justify-content: center; + align-items: center; + width: var(--colon-width); + margin-bottom: 0.3rem; +} + +.spacer { + width: var(--day-period-spacing-width); +}
\ No newline at end of file diff --git a/toolkit/themes/shared/datetimepopup.css b/toolkit/themes/shared/datetimepopup.css new file mode 100644 index 000000000..52f6fc7a2 --- /dev/null +++ b/toolkit/themes/shared/datetimepopup.css @@ -0,0 +1,11 @@ +/* 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/. */ + +@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); + +panel[type="arrow"][side="top"], +panel[type="arrow"][side="bottom"] { + margin-left: 0; + margin-right: 0; +} diff --git a/toolkit/themes/shared/icons/calendar-arrows.svg b/toolkit/themes/shared/icons/calendar-arrows.svg new file mode 100644 index 000000000..858676f55 --- /dev/null +++ b/toolkit/themes/shared/icons/calendar-arrows.svg @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"> + <style> + path:not(:target) { + display: none; + } + </style> + <path id="right" d="M4.8 14L3 12.3 8.5 7 3 1.7 4.8 0 12 7"/> + <path id="left" d="M9.2 0L11 1.7 5.5 7 11 12.3 9.2 14 2 7"/> +</svg> diff --git a/toolkit/themes/shared/icons/spinner-arrows.svg b/toolkit/themes/shared/icons/spinner-arrows.svg new file mode 100644 index 000000000..a8ba72d6b --- /dev/null +++ b/toolkit/themes/shared/icons/spinner-arrows.svg @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6" viewBox="0 0 10 6"> + <style> + path:not(:target) { + display: none; + } + </style> + <path id="down" d="M0 1l1-1 4 4 4-4 1 1-5 5"/> + <path id="up" d="M0 5l1 1 4-4 4 4 1-1-5-5"/> +</svg> diff --git a/toolkit/themes/shared/jar.inc.mn b/toolkit/themes/shared/jar.inc.mn index 9c3d86a40..bdfca2a05 100644 --- a/toolkit/themes/shared/jar.inc.mn +++ b/toolkit/themes/shared/jar.inc.mn @@ -21,12 +21,15 @@ toolkit.jar: skin/classic/global/aboutSupport.css (../../shared/aboutSupport.css) skin/classic/global/appPicker.css (../../shared/appPicker.css) skin/classic/global/config.css (../../shared/config.css) - skin/classic/global/timepicker.css (../../shared/timepicker.css) + skin/classic/global/datetimeinputpickers.css (../../shared/datetimeinputpickers.css) + skin/classic/global/datetimepopup.css (../../shared/datetimepopup.css) + skin/classic/global/icons/calendar-arrows.svg (../../shared/icons/calendar-arrows.svg) skin/classic/global/icons/find-arrows.svg (../../shared/icons/find-arrows.svg) skin/classic/global/icons/info.svg (../../shared/incontent-icons/info.svg) skin/classic/global/icons/input-clear.svg (../../shared/icons/input-clear.svg) skin/classic/global/icons/loading.png (../../shared/icons/loading.png) skin/classic/global/icons/loading@2x.png (../../shared/icons/loading@2x.png) + skin/classic/global/icons/spinner-arrows.svg (../../shared/icons/spinner-arrows.svg) skin/classic/global/icons/warning.svg (../../shared/incontent-icons/warning.svg) skin/classic/global/icons/blocked.svg (../../shared/incontent-icons/blocked.svg) skin/classic/global/alerts/alert-common.css (../../shared/alert-common.css) diff --git a/toolkit/themes/shared/timepicker.css b/toolkit/themes/shared/timepicker.css deleted file mode 100644 index e8d081b30..000000000 --- a/toolkit/themes/shared/timepicker.css +++ /dev/null @@ -1,153 +0,0 @@ -/* 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/. */ - -:root { - --font-size-default: 1.1rem; - --spinner-width: 3rem; - --spinner-margin-top-bottom: 0.4rem; - --spinner-item-height: 2.4rem; - --spinner-item-margin-bottom: 0.1rem; - --spinner-button-height: 1.2rem; - --colon-width: 2rem; - --day-period-spacing-width: 1rem; - - --border: 0.1rem solid #D6D6D6; - --border-radius: 0.3rem; - - --font-color: #191919; - --fill-color: #EBEBEB; - - --selected-font-color: #FFFFFF; - --selected-fill-color: #0996F8; - - --button-font-color: #858585; - --button-font-color-hover: #4D4D4D; - --button-font-color-active: #191919; - - --disabled-opacity: 0.2; -} - -html { - font-size: 10px; -} - -body { - margin: 0; - color: var(--font-color); - font-size: var(--font-size-default); -} - -#time-picker { - display: flex; - flex-direction: row; - justify-content: space-around; -} - -.spinner-container { - font-family: sans-serif; - display: flex; - flex-direction: column; - width: var(--spinner-width); -} - -.spinner-container > button { - -moz-appearance: none; - border: none; - background: none; - background-color: var(--button-font-color); - height: var(--spinner-button-height); -} - -.spinner-container > button:hover { - background-color: var(--button-font-color-hover); -} - -.spinner-container > button.active { - background-color: var(--button-font-color-active); -} - -.spinner-container > button.up { - mask: url("chrome://global/skin/icons/find-arrows.svg#glyph-find-previous") no-repeat 50% 50%; -} - -.spinner-container > button.down { - mask: url("chrome://global/skin/icons/find-arrows.svg#glyph-find-next") no-repeat 50% 50%; -} - -.spinner-container.hide-buttons > button { - visibility: hidden; -} - -.spinner-container > .spinner { - position: relative; - width: 100%; - margin: var(--spinner-margin-top-bottom) 0; - cursor: default; - overflow-y: scroll; - scroll-snap-type: mandatory; - scroll-snap-points-y: repeat(100%); -} - -.spinner-container > .spinner > div { - box-sizing: border-box; - position: relative; - text-align: center; - padding: calc((var(--spinner-item-height) - var(--font-size-default)) / 2) 0; - margin-bottom: var(--spinner-item-margin-bottom); - height: var(--spinner-item-height); - -moz-user-select: none; - scroll-snap-coordinate: 0 0; -} - -.spinner-container > .spinner > div:hover::before { - background: var(--fill-color); - border: var(--border); - border-radius: var(--border-radius); - content: ""; - position: absolute; - top: 0%; - bottom: 0%; - left: 0%; - right: 0%; - z-index: -10; -} - -.spinner-container > .spinner:not(.scrolling) > div.selection { - color: var(--selected-font-color); -} - -.spinner-container > .spinner > div.selection::before { - background: var(--selected-fill-color); - border: none; - border-radius: var(--border-radius); - content: ""; - position: absolute; - top: 0%; - bottom: 0%; - left: 0%; - right: 0%; - z-index: -10; -} - -.spinner-container > .spinner > div.disabled::before, -.spinner-container > .spinner.scrolling > div.selection::before, -.spinner-container > .spinner.scrolling > div:hover::before { - display: none; -} - -.spinner-container > .spinner > div.disabled { - opacity: var(--disabled-opacity); -} - -.colon { - display: flex; - justify-content: center; - align-items: center; - width: var(--colon-width); - margin-bottom: 0.3rem; -} - -.spacer { - width: var(--day-period-spacing-width); -}
\ No newline at end of file |