diff options
author | dbeam@chromium.org <dbeam@chromium.org@0039d316-1c4b-4281-b951-d872f2087c98> | 2014-08-14 23:47:37 +0000 |
---|---|---|
committer | dbeam@chromium.org <dbeam@chromium.org@0039d316-1c4b-4281-b951-d872f2087c98> | 2014-08-14 23:48:31 +0000 |
commit | 3da41f9378f9d075a94cc278f99ce4344f9acc7b (patch) | |
tree | 9430f9e3b76dba93ea4efbe250da33c8a1da69d3 | |
parent | b0eca279214bf99ba2fd4cea4e5ae72429a8cdb8 (diff) | |
download | chromium_src-3da41f9378f9d075a94cc278f99ce4344f9acc7b.zip chromium_src-3da41f9378f9d075a94cc278f99ce4344f9acc7b.tar.gz chromium_src-3da41f9378f9d075a94cc278f99ce4344f9acc7b.tar.bz2 |
history: rework focus management.
webui: add some resuable focus components (to be made more generic if desired).
BUG=393489
R=dmazzoni@chromium.org
Review URL: https://codereview.chromium.org/453783003
Cr-Commit-Position: refs/heads/master@{#289723}
git-svn-id: svn://svn.chromium.org/chrome/trunk/src@289723 0039d316-1c4b-4281-b951-d872f2087c98
-rw-r--r-- | chrome/browser/resources/history/history.css | 30 | ||||
-rw-r--r-- | chrome/browser/resources/history/history.html | 4 | ||||
-rw-r--r-- | chrome/browser/resources/history/history.js | 369 | ||||
-rw-r--r-- | ui/webui/resources/js/cr/ui/focus_grid.js | 103 | ||||
-rw-r--r-- | ui/webui/resources/js/cr/ui/focus_row.js | 197 | ||||
-rw-r--r-- | ui/webui/resources/webui_resources.grd | 6 |
6 files changed, 443 insertions, 266 deletions
diff --git a/chrome/browser/resources/history/history.css b/chrome/browser/resources/history/history.css index 0b28a41..351c1e5 100644 --- a/chrome/browser/resources/history/history.css +++ b/chrome/browser/resources/history/history.css @@ -245,7 +245,7 @@ html[dir='rtl'] #display-filter-controls label span.first-button-component, } .entry-box, -.site-domain-wrapper { +.site-domain-row { -webkit-align-items: center; cursor: default; display: -webkit-flex; @@ -260,6 +260,7 @@ html[dir='rtl'] #display-filter-controls label span.first-button-component, .site-domain-wrapper { cursor: pointer; + display: -webkit-flex; width: 100%; } @@ -344,7 +345,8 @@ html[dir='rtl'] .site-domain { white-space: nowrap; } -.entry input[type='checkbox'] { +.entry input[type='checkbox'], +.site-domain-row input[type='checkbox'] { -webkit-margin-end: 6px; line-height: 1em; min-width: 13px; @@ -415,14 +417,14 @@ html[dir='rtl'] .site-domain { } </if> -.entry-box { +.entry-box, +.site-domain-row { + -webkit-padding-end: 6px; -webkit-padding-start: 6px; border-radius: 2px; } -.entry-box:hover, -.entry-box.lead, -.entry-box.contains-focus { +.active :-webkit-any(.entry-box, .site-domain-row) { background-color: rgba(0, 0, 0, .05); } @@ -467,6 +469,7 @@ html[dir='rtl'] .site-domain { } .site-domain-arrow { + -webkit-transform: rotate(0); -webkit-transition: -webkit-transform 300ms linear; background: url(../disclosure_triangle_small.png) no-repeat; background-position: 5px 5px; @@ -478,22 +481,14 @@ html[dir='rtl'] .site-domain { width: 21px; } -.site-domain-arrow.collapse { - -webkit-transform: rotate(0); -} - -.site-domain-arrow.expand { - -webkit-transform: rotate(90deg); +html[dir='rtl'] .site-domain-arrow { + -webkit-transform: rotate(180deg); } -html[dir='rtl'] .site-domain-arrow.collapse { +html .expand .site-domain-arrow { -webkit-transform: rotate(90deg); } -html[dir='rtl'] .site-domain-arrow.expand { - -webkit-transform: rotate(180deg); -} - .entry .bookmark-section { -webkit-margin-end: 3px; -webkit-margin-start: 8px; @@ -528,6 +523,7 @@ html[dir='rtl'] .site-domain-arrow.expand { } button.menu-button.drop-down { + -webkit-margin-end: 0; min-width: 12px; top: 0; } diff --git a/chrome/browser/resources/history/history.html b/chrome/browser/resources/history/history.html index bc6d787..2bcc1c4 100644 --- a/chrome/browser/resources/history/history.html +++ b/chrome/browser/resources/history/history.html @@ -27,11 +27,15 @@ <link rel="stylesheet" href="other_devices.css"> </if> +<script src="chrome://resources/js/assert.js"></script> <script src="chrome://resources/js/event_tracker.js"></script> +<script src="chrome://resources/js/util.js"></script> <script src="chrome://resources/js/cr.js"></script> <script src="chrome://resources/js/cr/ui.js"></script> <script src="chrome://resources/js/cr/ui/command.js"></script> <script src="chrome://resources/js/cr/ui/focus_manager.js"></script> +<script src="chrome://resources/js/cr/ui/focus_row.js"></script> +<script src="chrome://resources/js/cr/ui/focus_grid.js"></script> <script src="chrome://resources/js/cr/ui/menu_item.js"></script> <script src="chrome://resources/js/cr/ui/menu.js"></script> <if expr="not is_android and not is_ios"> diff --git a/chrome/browser/resources/history/history.js b/chrome/browser/resources/history/history.js index a10cada..7b149c6 100644 --- a/chrome/browser/resources/history/history.js +++ b/chrome/browser/resources/history/history.js @@ -148,6 +148,7 @@ Visit.prototype.getResultDOM = function(propertyBag) { var isSearchResult = propertyBag.isSearchResult || false; var addTitleFavicon = propertyBag.addTitleFavicon || false; var useMonthDate = propertyBag.useMonthDate || false; + var focusless = propertyBag.focusless || false; var node = createElementWithClassName('li', 'entry'); var time = createElementWithClassName('div', 'time'); var entryBox = createElementWithClassName('label', 'entry-box'); @@ -163,18 +164,19 @@ Visit.prototype.getResultDOM = function(propertyBag) { checkbox.type = 'checkbox'; checkbox.id = 'checkbox-' + this.id_; checkbox.time = this.date.getTime(); - checkbox.tabIndex = -1; checkbox.addEventListener('click', checkboxClicked); entryBox.appendChild(checkbox); + if (focusless) + checkbox.tabIndex = -1; + // Clicking anywhere in the entryBox will check/uncheck the checkbox. entryBox.setAttribute('for', checkbox.id); entryBox.addEventListener('mousedown', entryBoxMousedown); entryBox.addEventListener('click', entryBoxClick); + entryBox.addEventListener('keydown', this.handleKeydown_.bind(this)); } - entryBox.addEventListener('keydown', this.handleKeydown_.bind(this)); - // Keep track of the drop down that triggered the menu, so we know // which element to apply the command to. // TODO(dubroy): Ideally we'd use 'activate', but MenuButton swallows it. @@ -209,9 +211,15 @@ Visit.prototype.getResultDOM = function(propertyBag) { visitEntryWrapper.classList.add('blocked-indicator'); visitEntryWrapper.appendChild(this.getVisitAttemptDOM_()); } else { - visitEntryWrapper.appendChild(this.getTitleDOM_(isSearchResult)); + var title = visitEntryWrapper.appendChild( + this.getTitleDOM_(isSearchResult)); + if (addTitleFavicon) this.addFaviconToElement_(visitEntryWrapper); + + if (focusless) + title.querySelector('a').tabIndex = -1; + visitEntryWrapper.appendChild(domain); } @@ -233,10 +241,12 @@ Visit.prototype.getResultDOM = function(propertyBag) { var dropDown = createElementWithClassName('button', 'drop-down'); dropDown.value = 'Open action menu'; dropDown.title = loadTimeData.getString('actionMenuDescription'); - dropDown.tabIndex = -1; dropDown.setAttribute('menu', '#action-menu'); dropDown.setAttribute('aria-haspopup', 'true'); + if (focusless) + dropDown.tabIndex = -1; + cr.ui.decorate(dropDown, MenuButton); dropDown.respondToArrowKeys = false; @@ -289,34 +299,6 @@ Visit.prototype.removeFromHistory = function() { }.bind(this)); }; -/** - * @param {boolean} isLead Whether this visit is the "lead" visit, i.e. the one - * that would be focused if the entry list is tabbed to. - */ -Visit.prototype.setIsLead = function(isLead) { - this.domNode_.querySelector('.entry-box').classList.toggle('lead', isLead); - if (!isLead) { - this.getFocusableControls_().forEach(function(control) { - control.tabIndex = -1; - }); - } -}; - -/** - * @param {Element} control A control element to focus. - */ -Visit.prototype.focusControl = function(control) { - var controls = this.getFocusableControls_(); - assert(controls.indexOf(control) >= 0); - - for (var i = 0; i < controls.length; ++i) { - controls[i].tabIndex = controls[i] == control ? 0 : -1; - } - - control.focus(); - this.setIsLead(true); -}; - Object.defineProperty(Visit.prototype, 'checkBox', { get: function() { return this.domNode_.querySelector('input[type=checkbox]'); @@ -387,7 +369,6 @@ Visit.prototype.getTitleDOM_ = function(isSearchResult) { link.href = this.url_; link.id = 'id-' + this.id_; link.target = '_top'; - link.tabIndex = -1; var integerId = parseInt(this.id_, 10); link.addEventListener('click', function() { recordUmaAction('HistoryPage_EntryLinkClick'); @@ -459,52 +440,14 @@ Visit.prototype.showMoreFromSite_ = function() { }; /** - * @return {Array.<Element>} A list of focusable controls. - * @private - */ -Visit.prototype.getFocusableControls_ = function() { - var controls = []; - - if (this.checkBox) - controls.push(this.checkBox); - - if (this.bookmarkStar) - controls.push(this.bookmarkStar); - - controls.push(this.titleLink); - - if (this.dropDown) - controls.push(this.dropDown); - - return controls; -}; - -/** * @param {Event} e A keydown event to handle. * @private */ Visit.prototype.handleKeydown_ = function(e) { - var key = e.keyIdentifier; - if (key == 'U+0008' || key == 'U+007F') { // Delete or Backspace. - if (!this.model_.isDeletingVisits()) - this.removeEntryFromHistory_(e); - return; - } - - var target = e.target; - if (target != document.activeElement || !(key == 'Left' || key == 'Right')) - return; - - var controls = this.getFocusableControls_(); - for (var i = 0; i < controls.length; ++i) { - if (controls[i].contains(target)) { - var toFocus = key == 'Left' ? controls[i - 1] : controls[i + 1]; - if (toFocus) { - this.focusControl(toFocus); - e.preventDefault(); - } - break; - } + // Delete or Backspace should delete the entry if allowed. + if ((e.keyIdentifier == 'U+0008' || e.keyIdentifier == 'U+007F') && + !this.model_.isDeletingVisits()) { + this.removeEntryFromHistory_(e); } }; @@ -876,6 +819,34 @@ HistoryModel.prototype.getGroupByDomain = function() { }; /////////////////////////////////////////////////////////////////////////////// +// HistoryFocusObserver: + +/** @implements {cr.ui.FocusRow.Observer} */ +function HistoryFocusObserver() {} + +HistoryFocusObserver.prototype = { + /** @override */ + onActivate: function(row) { + this.getActiveRowElement_(row).classList.add('active'); + }, + + /** @override */ + onDeactivate: function(row, el) { + this.getActiveRowElement_(row).classList.remove('active'); + }, + + /** + * @param {cr.ui.FocusRow} row The row to find an element for. + * @return {Element} |row|'s "active" element. + * @private + */ + getActiveRowElement_: function(row) { + return findAncestorByClass(row.items[0], 'entry') || + findAncestorByClass(row.items[0], 'site-domain-wrapper'); + }, +}; + +/////////////////////////////////////////////////////////////////////////////// // HistoryView: /** @@ -889,6 +860,8 @@ function HistoryView(model) { this.editButtonTd_ = $('edit-button'); this.editingControlsDiv_ = $('editing-controls'); this.resultDiv_ = $('results-display'); + this.focusGrid_ = new cr.ui.FocusGrid(this.resultDiv_, + new HistoryFocusObserver); this.pageDiv_ = $('results-pagination'); this.model_ = model; this.pageIndex_ = 0; @@ -950,15 +923,6 @@ function HistoryView(model) { else self.setOffset(0); }); - - this.resultDiv_.addEventListener( - 'keydown', this.handleKeydown_.bind(this)); - this.resultDiv_.addEventListener( - 'mousedown', this.handleMousedown_.bind(this), true); - this.resultDiv_.addEventListener( - 'focusin', this.updateFocusableElements_.bind(this)); - this.resultDiv_.addEventListener( - 'blur', this.updateFocusableElements_.bind(this)); } // HistoryView, public: ------------------------------------------------------- @@ -1081,7 +1045,7 @@ HistoryView.prototype.onModelReady = function(doneLoading) { var hasResults = this.model_.visits_.length > 0; document.body.classList.toggle('has-results', hasResults); - this.updateFocusableElements_(); + this.updateFocusGrid_(); this.updateNavBar_(); if (isMobileVersion()) { @@ -1132,8 +1096,15 @@ HistoryView.prototype.showNotification = function(innerHTML, isWarning) { */ HistoryView.prototype.onBeforeRemove = function(visit) { assert(this.currentVisits_.indexOf(visit) >= 0); - var toFocus = this.getVisitAfter_(visit) || this.getVisitBefore_(visit); - this.swapFocusedVisit_(toFocus); + + var pos = this.focusGrid_.getPositionForTarget(document.activeElement); + if (!pos) + return; + + var row = this.focusGrid_.rows[pos.row + 1] || + this.focusGrid_.rows[pos.row - 1]; + if (row) + row.focusIndex(Math.min(pos.col, row.items.length - 1)); }; /** @@ -1182,6 +1153,7 @@ HistoryView.prototype.removeVisit = function(visit) { for (var i = 0; i < toRemove.length; ++i) { removeNode(toRemove[i], onRemove, this); } + this.updateFocusGrid_(); var index = this.currentVisits_.indexOf(visit); if (index >= 0) @@ -1265,9 +1237,11 @@ HistoryView.prototype.getGroupedVisitsDOM_ = function( var siteResults = results.appendChild( createElementWithClassName('li', 'site-entry')); - // Make a wrapper that will contain the arrow, the favicon and the domain. var siteDomainWrapper = siteResults.appendChild( createElementWithClassName('div', 'site-domain-wrapper')); + // Make a row that will contain the arrow, the favicon and the domain. + var siteDomainRow = siteDomainWrapper.appendChild( + createElementWithClassName('div', 'site-domain-row')); if (this.model_.editingEntriesAllowed) { var siteDomainCheckbox = @@ -1276,13 +1250,12 @@ HistoryView.prototype.getGroupedVisitsDOM_ = function( siteDomainCheckbox.type = 'checkbox'; siteDomainCheckbox.addEventListener('click', domainCheckboxClicked); siteDomainCheckbox.domain_ = domain; - - siteDomainWrapper.appendChild(siteDomainCheckbox); + siteDomainRow.appendChild(siteDomainCheckbox); } - var siteArrow = siteDomainWrapper.appendChild( - createElementWithClassName('div', 'site-domain-arrow collapse')); - var siteDomain = siteDomainWrapper.appendChild( + var siteArrow = siteDomainRow.appendChild( + createElementWithClassName('div', 'site-domain-arrow')); + var siteDomain = siteDomainRow.appendChild( createElementWithClassName('div', 'site-domain')); var siteDomainLink = siteDomain.appendChild( createElementWithClassName('button', 'link-button')); @@ -1297,10 +1270,11 @@ HistoryView.prototype.getGroupedVisitsDOM_ = function( domainVisits[0].addFaviconToElement_(siteDomain); - siteDomainWrapper.addEventListener('click', toggleHandler); + siteDomainWrapper.addEventListener( + 'click', this.toggleGroupedVisits_.bind(this)); if (this.model_.isSupervisedProfile) { - siteDomainWrapper.appendChild( + siteDomainRow.appendChild( getFilteringStatusDOM(domainVisits[0].hostFilteringBehavior)); } @@ -1316,7 +1290,8 @@ HistoryView.prototype.getGroupedVisitsDOM_ = function( var isMonthGroupedResult = this.getRangeInDays() == HistoryModel.Range.MONTH; for (var j = 0, visit; visit = domainVisits[j]; j++) { resultsList.appendChild(visit.getResultDOM({ - useMonthDate: isMonthGroupedResult + focusless: true, + useMonthDate: isMonthGroupedResult, })); this.setVisitRendered_(visit); } @@ -1563,6 +1538,34 @@ HistoryView.prototype.displayResults_ = function(doneLoading) { this.setTimeColumnWidth_(this.resultDiv_); }; +var focusGridRowSelector = [ + '.day-results > .entry:not(.fade-out)', + '.expand .grouped .entry:not(.fade-out)', + '.site-domain-wrapper' +].join(', '); + +var focusGridColumnSelector = [ + '.entry-box input', + '.bookmark-section.starred', + '.title a', + '.drop-down', + '.domain-checkbox', + '.link-button', +].join(', '); + +/** @private */ +HistoryView.prototype.updateFocusGrid_ = function() { + var rows = this.resultDiv_.querySelectorAll(focusGridRowSelector); + var grid = []; + + for (var i = 0; i < rows.length; ++i) { + assert(rows[i].parentNode); + grid.push(rows[i].querySelectorAll(focusGridColumnSelector)); + } + + this.focusGrid_.setGrid(grid); +}; + /** * Update the visibility of the page navigation buttons. * @private @@ -1625,134 +1628,32 @@ HistoryView.prototype.setTimeColumnWidth_ = function() { }; /** - * @param {Visit} visit The starting point when looking for a previous visit. - * @return {Visit|undefined} The previous visit (if there is one) or undefined. - * @private - */ -HistoryView.prototype.getVisitBefore_ = function(visit) { - var index = this.currentVisits_.indexOf(visit); - return index < 0 ? undefined : this.currentVisits_[index - 1]; -}; - -/** - * @param {Visit} visit The starting point when looking for a previous visit. - * @return {Visit|undefined} The next visit (if there is one) or undefined. - * @private - */ -HistoryView.prototype.getVisitAfter_ = function(visit) { - var index = this.currentVisits_.indexOf(visit); - return index < 0 ? undefined : this.currentVisits_[index + 1]; -}; - -/** - * Swaps focus to |toBeFocused|. Assumes the another visit is currently focused. - * @param {Visit} visit A visit to focus. - * @private - */ -HistoryView.prototype.swapFocusedVisit_ = function(visit) { - if (!visit) - return; - - var activeVisit = findAncestorByClass(document.activeElement, 'entry').visit; - var controls = activeVisit.getFocusableControls_(); - - for (var i = 0; i < controls.length; ++i) { - var control = controls[i]; - if (!control.contains(document.activeElement)) - continue; - - // Try to focus the same type of control if the new visit has it. - if (control == activeVisit.checkBox && visit.checkBox) { - visit.focusControl(visit.checkBox); - } else if (control == activeVisit.bookmarkStar && visit.bookmarkStar) { - visit.focusControl(visit.bookmarkStar); - } else if (control == activeVisit.titleLink) { - visit.focusControl(visit.titleLink); - } else if (control == activeVisit.dropDown && visit.dropDown) { - visit.focusControl(visit.dropDown); - } else { - // Otherwise, just focus something that might be in a similar column. - var controlsToFocus = visit.getFocusableControls_(); - var indexToFocus = Math.min(i, controlsToFocus.length - 1); - visit.focusControl(controlsToFocus[indexToFocus]); - } - break; - } - - activeVisit.setIsLead(false); -}; - -/** - * @param {Event} e A keydown event to handle. - * @private - */ -HistoryView.prototype.handleKeydown_ = function(e) { - // Only handle up or down arrows on the focused element. - var key = e.keyIdentifier, target = e.target; - if (target != document.activeElement || !(key == 'Up' || key == 'Down')) - return; - - var entry = findAncestorByClass(e.target, 'entry'); - var visit = entry && entry.visit; - this.swapFocusedVisit_(key == 'Up' ? this.getVisitBefore_(visit) : - this.getVisitAfter_(visit)); -}; - -/** - * @param {Event} e A mousedown event to handle. - * @private - */ -HistoryView.prototype.handleMousedown_ = function(e) { - var target = e.target; - var entry = findAncestorByClass(target, 'entry'); - if (!entry || !entry.contains(target)) - return; - - var visit = entry.visit; - if (visit.bookmarkStar && visit.bookmarkStar.contains(target)) - return; - - if (visit.titleLink.contains(target)) - visit.focusControl(visit.titleLink); - else if (visit.dropDown && visit.dropDown.contains(target)) - visit.focusControl(visit.dropDown); - else // Focus the checkbox by default. If no checkbox, focus the title. - visit.focusControl(visit.checkBox || visit.titleLink); - - e.preventDefault(); -}; - -/** - * Ensures there's only 1 focusable visit. + * Toggles an element in the grouped history. + * @param {Element} e The element which was clicked on. * @private */ -HistoryView.prototype.updateFocusableElements_ = function() { - var focusable = Array.prototype.slice.call( - this.resultDiv_.querySelectorAll('[tabindex="0"]')); - - // Don't change the tabIndex of the first [tabindex=0] node or the active - // element if either are in |focusable|. - focusable.splice(Math.max(0, focusable.indexOf(document.activeElement)), 1); +HistoryView.prototype.toggleGroupedVisits_ = function(e) { + var entry = findAncestorByClass(e.target, 'site-entry'); + var innerResultList = entry.querySelector('.site-results'); - for (var i = 0; i < focusable.length; ++i) { - var el = focusable[i]; - var entry = findAncestorByClass(el, 'entry'); - if (!entry.contains(document.activeElement)) - entry.visit.setIsLead(false); - else - el.tabIndex = -1; + if (entry.classList.contains('expand')) { + innerResultList.style.height = 0; + } else { + innerResultList.style.height = 'auto'; + // -webkit-transition does not work on height:auto elements so first set + // the height to auto so that it is computed and then set it to the + // computed value in pixels so the transition works properly. + var height = innerResultList.clientHeight; + innerResultList.style.height = 0; + setTimeout(function() { + innerResultList.style.height = height + 'px'; + }, 0); } - // If there's no focusable elements, allow the first visit to be focused. - if (!this.resultDiv_.querySelector('[tabindex="0"]') && - this.currentVisits_.length > 0) { - var firstVisit = this.currentVisits_[0]; - firstVisit.setIsLead(true); - (firstVisit.checkBox || firstVisit.titleLink).tabIndex = 0; - } + entry.classList.toggle('expand'); + this.updateFocusGrid_(); }; - /////////////////////////////////////////////////////////////////////////////// // State object: /** @@ -1940,8 +1841,6 @@ function load() { window.addEventListener('resize', historyView.positionNotificationBar.bind(historyView)); - cr.ui.FocusManager.disableMouseFocusOnButtons(); - if (isMobileVersion()) { // Move the search box out of the header. var resultsDisplay = $('results-display'); @@ -2186,7 +2085,7 @@ function updateParentCheckbox(checkbox) { var groupCheckbox = entry.querySelector('.site-domain-wrapper input'); if (groupCheckbox) - groupCheckbox.checked = false; + groupCheckbox.checked = false; } function entryBoxMousedown(event) { @@ -2253,34 +2152,6 @@ function removeNode(node, onRemove, opt_scope) { } /** - * Toggles an element in the grouped history. - * @param {Element} e The element which was clicked on. - */ -function toggleHandler(e) { - var innerResultList = e.currentTarget.parentElement.querySelector( - '.site-results'); - var innerArrow = e.currentTarget.parentElement.querySelector( - '.site-domain-arrow'); - if (innerArrow.classList.contains('collapse')) { - innerResultList.style.height = 'auto'; - // -webkit-transition does not work on height:auto elements so first set - // the height to auto so that it is computed and then set it to the - // computed value in pixels so the transition works properly. - var height = innerResultList.clientHeight; - innerResultList.style.height = 0; - setTimeout(function() { - innerResultList.style.height = height + 'px'; - }, 0); - innerArrow.classList.remove('collapse'); - innerArrow.classList.add('expand'); - } else { - innerResultList.style.height = 0; - innerArrow.classList.remove('expand'); - innerArrow.classList.add('collapse'); - } -} - -/** * Builds the DOM elements to show the filtering status of a domain/URL. * @param {SupervisedUserFilteringBehavior} filteringBehavior The filter * behavior for this item. diff --git a/ui/webui/resources/js/cr/ui/focus_grid.js b/ui/webui/resources/js/cr/ui/focus_grid.js new file mode 100644 index 0000000..713a838 --- /dev/null +++ b/ui/webui/resources/js/cr/ui/focus_grid.js @@ -0,0 +1,103 @@ +// Copyright 2014 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +cr.define('cr.ui', function() { + /** + * A class to manage grid of focusable elements in a 2D grid. For example, + * given this grid: + * + * focusable [focused] focusable (row: 0, col: 1) + * focusable focusable focusable + * focusable focusable focusable + * + * Pressing the down arrow would result in the focus moving down 1 row and + * keeping the same column: + * + * focusable focusable focusable + * focusable [focused] focusable (row: 1, col: 1) + * focusable focusable focusable + * + * And pressing right at this point would move the focus to: + * + * focusable focusable focusable + * focusable focusable [focused] (row: 1, col: 2) + * focusable focusable focusable + * + * @param {Node=} opt_boundary Ignore focus events outside this node. + * @param {cr.ui.FocusRow.Observer=} opt_observer An observer of rows. + * @implements {cr.ui.FocusRow.Delegate} + * @constructor + */ + function FocusGrid(opt_boundary, opt_observer) { + /** @type {Node|undefined} */ + this.boundary_ = opt_boundary; + + /** @private {cr.ui.FocusRow.Observer|undefined} */ + this.observer_ = opt_observer; + + /** @type {!Array.<!cr.ui.FocusRow>} */ + this.rows = []; + } + + FocusGrid.prototype = { + /** + * @param {EventTarget} target A target item to find in this grid. + * @return {?{row: number, col: number}} A position or null if not found. + */ + getPositionForTarget: function(target) { + for (var i = 0; i < this.rows.length; ++i) { + for (var j = 0; j < this.rows[i].items.length; ++j) { + if (target == this.rows[i].items[j]) + return {row: i, col: j}; + } + } + return null; + }, + + /** @override */ + onKeydown: function(keyRow, e) { + var rowIndex = this.rows.indexOf(keyRow); + assert(rowIndex >= 0); + + var row = -1; + + if (e.keyIdentifier == 'Up') + row = rowIndex - 1; + else if (e.keyIdentifier == 'Down') + row = rowIndex + 1; + else if (e.keyIdentifier == 'PageUp') + row = 0; + else if (e.keyIdentifier == 'PageDown') + row = this.rows.length - 1; + + if (!this.rows[row]) + return; + + var colIndex = keyRow.items.indexOf(e.target); + var col = Math.min(colIndex, this.rows[row].items.length - 1); + + this.rows[row].focusIndex(col); + + e.preventDefault(); + }, + + /** + * @param {!Array.<!NodeList|!Array.<!Element>>} grid A 2D array of nodes. + */ + setGrid: function(grid) { + this.rows.forEach(function(row) { row.destroy(); }); + + this.rows = grid.map(function(row) { + return new cr.ui.FocusRow(row, this.boundary_, this, this.observer_); + }, this); + + if (!this.getPositionForTarget(document.activeElement) && this.rows[0]) + this.rows[0].activeIndex = 0; + }, + }; + + return { + FocusGrid: FocusGrid, + }; +}); diff --git a/ui/webui/resources/js/cr/ui/focus_row.js b/ui/webui/resources/js/cr/ui/focus_row.js new file mode 100644 index 0000000..9f3495d --- /dev/null +++ b/ui/webui/resources/js/cr/ui/focus_row.js @@ -0,0 +1,197 @@ +// Copyright 2014 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +cr.define('cr.ui', function() { + /** + * A class to manage focus between given horizontally arranged elements. + * For example, given the page: + * + * <input type="checkbox"> <label>Check me!</label> <button>X</button> + * + * One could create a FocusRow by doing: + * + * new cr.ui.FocusRow([checkboxEl, labelEl, buttonEl]) + * + * if there are references to each node or querying them from the DOM like so: + * + * new cr.ui.FocusRow(dialog.querySelectorAll('list input[type=checkbox]')) + * + * Pressing left cycles backward and pressing right cycles forward in item + * order. Pressing Home goes to the beginning of the list and End goes to the + * end of the list. + * + * If an item in this row is focused, it'll stay active (accessible via tab). + * If no items in this row are focused, the row can stay active until focus + * changes to a node inside |this.boundary_|. If opt_boundary isn't + * specified, any focus change deactivates the row. + * + * @param {!Array.<!Element>|!NodeList} items Elements to track focus of. + * @param {Node=} opt_boundary Focus events are ignored outside of this node. + * @param {FocusRow.Delegate=} opt_delegate A delegate to handle key events. + * @param {FocusRow.Observer=} opt_observer An observer that's notified if + * this focus row is added to or removed from the focus order. + * @constructor + */ + function FocusRow(items, opt_boundary, opt_delegate, opt_observer) { + /** @type {!Array.<!Element>} */ + this.items = Array.prototype.slice.call(items); + assert(this.items.length > 0); + + /** @type {!Node} */ + this.boundary_ = opt_boundary || document; + + /** @private {FocusRow.Delegate|undefined} */ + this.delegate_ = opt_delegate; + + /** @private {FocusRow.Observer|undefined} */ + this.observer_ = opt_observer; + + /** @private {!EventTracker} */ + this.eventTracker_ = new EventTracker; + this.eventTracker_.add(cr.doc, 'focusin', this.onFocusin_.bind(this)); + this.eventTracker_.add(cr.doc, 'keydown', this.onKeydown_.bind(this)); + + this.items.forEach(function(item) { + if (item != document.activeElement) + item.tabIndex = -1; + + this.eventTracker_.add(item, 'click', this.onClick_.bind(this)); + }, this); + + /** + * The index that should be actively participating in the page tab order. + * @type {number} + * @private + */ + this.activeIndex_ = this.items.indexOf(document.activeElement); + } + + /** @interface */ + FocusRow.Delegate = function() {}; + + FocusRow.Delegate.prototype = { + /** + * Called when a key is pressed while an item in |this.items| is focused. If + * |e|'s default is prevented, further processing is skipped. + * @param {cr.ui.FocusRow} row The row that detected a keydown. + * @param {Event} e The keydown event. + */ + onKeydown: assertNotReached, + }; + + /** @interface */ + FocusRow.Observer = function() {}; + + FocusRow.Observer.prototype = { + /** + * Called when the row is activated (added to the focus order). + * @param {cr.ui.FocusRow} row The row added to the focus order. + */ + onActivate: assertNotReached, + + /** + * Called when the row is deactivated (removed from the focus order). + * @param {cr.ui.FocusRow} row The row removed from the focus order. + */ + onDeactivate: assertNotReached, + }; + + FocusRow.prototype = { + get activeIndex() { + return this.activeIndex_; + }, + set activeIndex(index) { + var wasActive = this.items[this.activeIndex_]; + if (wasActive) + wasActive.tabIndex = -1; + + this.items.forEach(function(item) { assert(item.tabIndex == -1); }); + this.activeIndex_ = index; + + if (this.items[index]) + this.items[index].tabIndex = 0; + + if (!this.observer_) + return; + + var isActive = index >= 0 && index < this.items.length; + if (isActive == !!wasActive) + return; + + if (isActive) + this.observer_.onActivate(this); + else + this.observer_.onDeactivate(this); + }, + + /** + * Focuses the item at |index|. + * @param {number} index An index to focus. Must be between 0 and + * this.items.length - 1. + */ + focusIndex: function(index) { + this.items[index].focus(); + }, + + /** Call this to clean up event handling before dereferencing. */ + destroy: function() { + this.eventTracker_.removeAll(); + }, + + /** + * @param {Event} e The focusin event. + * @private + */ + onFocusin_: function(e) { + if (this.boundary_.contains(e.target)) + this.activeIndex = this.items.indexOf(e.target); + }, + + /** + * @param {Event} e A focus event. + * @private + */ + onKeydown_: function(e) { + var item = this.items.indexOf(e.target); + if (item < 0) + return; + + if (this.delegate_) + this.delegate_.onKeydown(this, e); + + if (e.defaultPrevented) + return; + + var index = -1; + + if (e.keyIdentifier == 'Left') + index = item + (isRTL() ? 1 : -1); + else if (e.keyIdentifier == 'Right') + index = item + (isRTL() ? -1 : 1); + else if (e.keyIdentifier == 'Home') + index = 0; + else if (e.keyIdentifier == 'End') + index = this.items.length - 1; + + if (!this.items[index]) + return; + + this.focusIndex(index); + e.preventDefault(); + }, + + /** + * @param {Event} e A click event. + * @private + */ + onClick_: function(e) { + if (!e.button) + this.activeIndex = this.items.indexOf(e.currentTarget); + }, + }; + + return { + FocusRow: FocusRow, + }; +}); diff --git a/ui/webui/resources/webui_resources.grd b/ui/webui/resources/webui_resources.grd index 19be267..da77c2d 100644 --- a/ui/webui/resources/webui_resources.grd +++ b/ui/webui/resources/webui_resources.grd @@ -247,6 +247,8 @@ without changes to the corresponding grd file. --> <structure name="IDR_WEBUI_CSS_WIDGETS" file="css/widgets.css" type="chrome_html" flattenhtml="true" /> + <structure name="IDR_WEBUI_JS_ASSERT" + file="js/assert.js" type="chrome_html" /> <structure name="IDR_WEBUI_JS_CR" file="js/cr.js" type="chrome_html" /> <structure name="IDR_WEBUI_JS_CR_EVENT_TARGET" @@ -281,11 +283,15 @@ without changes to the corresponding grd file. --> file="js/cr/ui/dialogs.js" type="chrome_html" /> <structure name="IDR_WEBUI_JS_CR_UI_DRAG_WRAPPER" file="js/cr/ui/drag_wrapper.js" type="chrome_html" /> + <structure name="IDR_WEBUI_JS_CR_UI_FOCUS_GRID" + file="js/cr/ui/focus_grid.js" type="chrome_html" /> <structure name="IDR_WEBUI_JS_CR_UI_FOCUS_MANAGER" file="js/cr/ui/focus_manager.js" type="chrome_html" /> <structure name="IDR_WEBUI_JS_CR_UI_FOCUS_OUTLINE_MANAGER" file="js/cr/ui/focus_outline_manager.js" type="chrome_html" /> + <structure name="IDR_WEBUI_JS_CR_UI_FOCUS_ROW" + file="js/cr/ui/focus_row.js" type="chrome_html" /> <structure name="IDR_WEBUI_JS_CR_UI_LIST" file="js/cr/ui/list.js" type="chrome_html" /> <structure name="IDR_WEBUI_JS_CR_UI_LIST_ITEM" |