Commit c81bef10 authored by Alex Danilo's avatar Alex Danilo Committed by Commit Bot

Add breadcrumbs with drop-down elided button

Adds a custom element implementation courtesy of noel@chromium.org
that handles layout of a set of navigation buttons that elide when
there are more than 4 sub-path entries, and presents a drop-down
menu containing the elided sub-paths.

Hook this custom element into the existing location line code for
files-ng. Add its source file to the BUILD and to closure compile
build dependencies.

Bug: 1035691
Change-Id: I85f7cf3c552320775dd34bbf5cf96a422b355f67
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2079773
Commit-Queue: Alex Danilo <adanilo@chromium.org>
Commit-Queue: Noel Gordon <noel@chromium.org>
Reviewed-by: default avatarNoel Gordon <noel@chromium.org>
Cr-Commit-Position: refs/heads/master@{#747035}
parent ac8ec157
...@@ -456,19 +456,26 @@ test.util.sync.unload = contentWindow => { ...@@ -456,19 +456,26 @@ test.util.sync.unload = contentWindow => {
}; };
/** /**
* Obtains the path which is shown in the breadcrumb. * Returns the path shown in the location line breadcrumb.
* *
* @param {Window} contentWindow Window to be tested. * @param {Window} contentWindow Window to be tested.
* @return {string} Path which is shown in the breadcrumb. * @return {string} The breadcrumb path.
*/ */
test.util.sync.getBreadcrumbPath = contentWindow => { test.util.sync.getBreadcrumbPath = contentWindow => {
const breadcrumb = const breadcrumb =
contentWindow.document.querySelector('#location-breadcrumbs'); contentWindow.document.querySelector('#location-breadcrumbs');
const paths = breadcrumb.querySelectorAll('.breadcrumb-path');
let path = ''; let path = '';
for (let i = 0; i < paths.length; i++) {
path += '/' + paths[i].textContent; if (util.isFilesNg()) {
const crumbs = breadcrumb.querySelector('bread-crumb');
if (crumbs) {
path = '/' + crumbs.path;
}
} else {
const paths = breadcrumb.querySelectorAll('.breadcrumb-path');
for (let i = 0; i < paths.length; i++) {
path += '/' + paths[i].textContent;
}
} }
return path; return path;
}; };
......
...@@ -156,6 +156,7 @@ ...@@ -156,6 +156,7 @@
// <include src="providers_model.js"> // <include src="providers_model.js">
// <include src="ui/actions_submenu.js"> // <include src="ui/actions_submenu.js">
// <include src="ui/banners.js"> // <include src="ui/banners.js">
// <include src="ui/breadcrumb.js">
// <include src="ui/default_task_dialog.js"> // <include src="ui/default_task_dialog.js">
// <include src="ui/dialog_footer.js"> // <include src="ui/dialog_footer.js">
// <include src="ui/directory_tree.js"> // <include src="ui/directory_tree.js">
......
...@@ -22,6 +22,7 @@ js_type_check("closure_compile_module") { ...@@ -22,6 +22,7 @@ js_type_check("closure_compile_module") {
":action_model_ui", ":action_model_ui",
":actions_submenu", ":actions_submenu",
":banners", ":banners",
":breadcrumb",
":closure_compile_externs", ":closure_compile_externs",
":combobutton", ":combobutton",
":commandbutton", ":commandbutton",
...@@ -121,6 +122,9 @@ js_library("banners") { ...@@ -121,6 +122,9 @@ js_library("banners") {
externs_list = [ "//ui/file_manager/externs/chrome_echo_private.js" ] externs_list = [ "//ui/file_manager/externs/chrome_echo_private.js" ]
} }
js_library("breadcrumb") {
}
js_library("combobutton") { js_library("combobutton") {
deps = [ deps = [
":files_menu", ":files_menu",
...@@ -247,6 +251,7 @@ js_library("file_manager_ui") { ...@@ -247,6 +251,7 @@ js_library("file_manager_ui") {
":action_model_ui", ":action_model_ui",
":actions_submenu", ":actions_submenu",
":banners", ":banners",
":breadcrumb",
":combobutton", ":combobutton",
":default_task_dialog", ":default_task_dialog",
":dialog_footer", ":dialog_footer",
......
// Copyright 2020 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.
/**
* @const {string} breadCrumbTemplate
*/
const breadCrumbTemplate = `
<style>
:host([hidden]), [hidden] {
display: none;
}
:host {
display: inline-flex;
font-family: 'Roboto Medium';
font-size: 14px;
outline: none;
overflow: hidden;
user-select: none;
white-space: nowrap;
}
p {
margin: 0;
}
p + p:before {
content: '>';
min-width: 20px;
margin: 0;
vertical-align: middle;
}
p[hidden] button[elider] {
display: none;
}
button {
/* don't use browser's background-color. */
background-color: unset;
border: 1px solid transparent;
color: var(--google-grey-700);
cursor: pointer;
/* don't use browser's button font. */
font: inherit;
margin: 0;
/* text rendering debounce: fix a minimum width. */
min-width: 1.2em;
/* elide wide text */
// TODO(crbug.com/1035691) Find out expected width.
max-width: 40em;
overflow: hidden;
padding: calc(8px - 1px);
/* text rendering debounce: center. */
text-align: center;
text-overflow: ellipsis;
vertical-align: middle;
}
button[disabled] {
color: var(--google-grey-900);
cursor: default;
font-weight: 500;
}
button:focus {
border: 1px solid blue;
outline: none;
}
button:active {
background-color: lightblue;
}
#elided, #elided button { /* drop-down menu and buttons */
display: none;
}
:host([checked]) #elided {
border: 1px solid gray;
background-color: white;
display: block;
margin-block-start: 0.2em;
margin-inline-start: -0.2em;
max-height: 40vh;
position: absolute;
overflow: hidden auto;
z-index: 502;
}
:host([checked]) #elided button {
display: block;
min-width: 14em; /* menu width */
max-width: 14em;
text-align: start;
}
</style>
<p hidden><button id='first'></button></p>
<p hidden><button elider></button></p>
<p hidden><button id='second'></button></p>
<p hidden><button id='third'></button></p>
<p hidden><button id='fourth'></button></p>
`;
/**
* Class breadCrumb.
*/
class breadCrumb extends HTMLElement {
constructor() {
/**
* Create element content.
*/
super().attachShadow({mode: 'open'}).innerHTML = breadCrumbTemplate;
/**
* User interaction signals callback.
* @private @type {!function(*)}
*/
this.signal_ = console.log;
/**
* BreadCrumb path parts.
* @private @type {!Array<string>}
*/
this.parts_ = [];
}
/**
* Sets the user interaction signal callback.
* @param {?function(*)} signal
*/
setSignalCallback(signal) {
this.signal_ = signal || console.log;
}
/**
* DOM connected.
* @private
*/
connectedCallback() {
this.onkeydown = this.onKeydown_.bind(this);
this.onclick = this.onClicked_.bind(this);
this.onblur = this.closeMenu_.bind(this);
this.setAttribute('tabindex', '0');
}
/**
* Get parts.
* @return {!Array<string>}
*/
get parts() {
return this.parts_;
}
/**
* Get path.
* @return {string} path
*/
get path() {
return this.parts_.join('/');
}
/**
* Sets the path: update parts from |path|. Emits a 'path-updated' _before_
* updating the parts <button> element content to the new |path|.
*
* @param {string} path
*/
set path(path) {
this.parts_ = path ? path.split('/') : [];
this.signal_('path-updated');
this.renderParts_();
}
/**
* Renders the path <button> parts. Emits 'path-rendered' signal.
*
* @private
*/
renderParts_() {
const buttons = this.shadowRoot.querySelectorAll('button[id]');
const enabled = [];
function setButton(i, text) {
buttons[i].removeAttribute('has-tooltip');
buttons[i].parentElement.hidden = !text;
buttons[i].textContent = text;
buttons[i].disabled = false;
!!text && enabled.push(i);
}
const parts = this.parts_;
setButton(0, parts.length > 0 ? parts[0] : null);
setButton(1, parts.length == 4 ? parts[parts.length - 3] : null);
buttons[1].hidden = parts.length != 4;
setButton(2, parts.length > 2 ? parts[parts.length - 2] : null);
setButton(3, parts.length > 1 ? parts[parts.length - 1] : null);
if (enabled.length) { // Disable the "last" button.
buttons[enabled.pop()].disabled = true;
}
this.removeAttribute('checked');
this.renderElidedParts_();
this.setAttribute('path', this.path);
this.signal_('path-rendered');
}
/**
* Renders the elided parts of the path in a drop-down menu. Note the drop-
* down is hidden, via its parent, if there are no elided parts.
*
* @private
*/
renderElidedParts_() {
const elider = this.shadowRoot.querySelector('button[elider]');
const parts = this.parts_;
let content = '...';
elider.parentElement.hidden = parts.length <= 4;
if (elider.parentElement.hidden) {
elider.textContent = content;
return;
}
content += '<div id="elided">';
for (let i = 1; i < parts.length - 2; ++i) {
content += `<button tabindex='-1'>${parts[i]}</button>`;
}
elider.innerHTML = content + '</div>';
}
/**
* Returns the breadcrumb buttons: they contain the current path ordered by
* its parts, which are stored in the <button>.textContent.
*
* @return {!NodeList}
*/
getBreadcrumbButtons() {
const parts = 'button:not([elider]):not([hidden])';
return this.shadowRoot.querySelectorAll(parts);
}
/**
* Returns the visible buttons rendered CSS overflow: ellipsis that have no
* 'has-tooltip' attribute.
*
* Note: call in a requestAnimationFrame() to avoid a style resolve.
*
* @return {!Array<HTMLButtonElement>} buttons Callers can set the tool tip
* attribute on the returned buttons.
*/
getEllipsisButtons() {
return Array.from(this.getBreadcrumbButtons()).filter(button => {
if (!button.hasAttribute('has-tooltip') && button.offsetWidth) {
return button.offsetWidth < button.scrollWidth;
}
});
}
/**
* Handles 'click' events.
*
* Emits an index signal on breadcumb button click: the index indicates the
* current path part that was clicked. Drop-down menu clicks open and close
* (toggle) the menu element.
*
* @param {Event} event
* @private
*/
onClicked_(event) {
event.stopImmediatePropagation();
event.preventDefault();
if (event.repeat) {
return;
}
const element = event.path[0];
if (element.hasAttribute('elider')) {
this.toggleMenu_();
return;
}
this.closeMenu_();
if (element instanceof HTMLButtonElement) {
const parts = Array.from(this.getBreadcrumbButtons());
this.signal_(parts.indexOf(element));
}
}
/**
* Handles keyboard events.
*
* @param {Event} event
* @private
*/
onKeydown_(event) {
if (event.key === 'Tab') {
this.closeMenu_();
}
if (event.key === ' ' || event.key === 'Enter') {
this.onClicked_(event);
}
}
/**
* Toggles drop-down menu: opens if closed and emits 'path-rendered' signal
* or closes if open via closeMenu_.
*
* @private
*/
toggleMenu_() {
if (!this.hasAttribute('checked')) {
this.setAttribute('checked', '');
this.signal_('path-rendered');
} else {
this.closeMenu_();
}
}
/**
* Closes drop-down menu if needed.
*
* @private
*/
closeMenu_() {
if (this.hasAttribute('checked')) {
this.removeAttribute('checked');
}
}
}
customElements.define('bread-crumb', breadCrumb);
...@@ -45,7 +45,12 @@ class LocationLine extends cr.EventTarget { ...@@ -45,7 +45,12 @@ class LocationLine extends cr.EventTarget {
return; return;
} }
this.update_(this.getComponents_(entry)); const components = this.getComponents_(entry);
if (util.isFilesNg()) {
this.updateNg_(components);
} else {
this.update_(components);
}
} }
/** /**
...@@ -179,6 +184,31 @@ class LocationLine extends cr.EventTarget { ...@@ -179,6 +184,31 @@ class LocationLine extends cr.EventTarget {
return components; return components;
} }
/**
* Updates the breadcrumb display for files-ng.
* @param {!Array<!LocationLine.PathComponent>} components Components to the
* target path.
* @private
*/
updateNg_(components) {
this.components_ = components;
let breadcrumbs = document.querySelector('bread-crumb');
if (!breadcrumbs) {
breadcrumbs = document.createElement('bread-crumb');
this.breadcrumbs_.appendChild(breadcrumbs);
breadcrumbs.setSignalCallback(this.breadCrumbSignal_.bind(this));
}
let crumbPath = components[0].name;
for (let i = 1; i < components.length; i++) {
crumbPath += '/' + components[i].name;
}
breadcrumbs.path = crumbPath;
this.breadcrumbs_.hidden = false;
}
/** /**
* Updates the breadcrumb display. * Updates the breadcrumb display.
* @param {!Array<!LocationLine.PathComponent>} components Components to the * @param {!Array<!LocationLine.PathComponent>} components Components to the
...@@ -387,24 +417,13 @@ class LocationLine extends cr.EventTarget { ...@@ -387,24 +417,13 @@ class LocationLine extends cr.EventTarget {
} }
/** /**
* Execute an element. * Navigate to a Path component.
* @param {number} index The index of clicked path component. * @param {number} index The index of clicked path component.
* @param {!Event} event The MouseEvent object.
* @private
*/ */
onClick_(index, event) { navigateToIndex_(index) {
let button = event.target;
// Remove 'focused' state from the clicked button.
while (button && !button.classList.contains('breadcrumb-path')) {
button = button.parentElement;
}
if (button) {
button.blur();
}
// Last breadcrumb component is the currently selected folder, skip // Last breadcrumb component is the currently selected folder, skip
// navigation and just move the focus to file list. // navigation and just move the focus to file list.
// TODO(files-ng): this if clause is not used or needed by files-ng.
if (index >= this.components_.length - 1) { if (index >= this.components_.length - 1) {
this.listContainer_.focus(); this.listContainer_.focus();
return; return;
...@@ -418,6 +437,35 @@ class LocationLine extends cr.EventTarget { ...@@ -418,6 +437,35 @@ class LocationLine extends cr.EventTarget {
}); });
metrics.recordUserAction('ClickBreadcrumbs'); metrics.recordUserAction('ClickBreadcrumbs');
} }
/**
* Signal handler for the bread-crumb element.
* @param {string} signal Identifier of which bread crumb was activated.
*/
breadCrumbSignal_(signal) {
if (typeof signal === 'number') {
this.navigateToIndex_(Number(signal));
}
}
/**
* Execute an element.
* @param {number} index The index of clicked path component.
* @param {!Event} event The MouseEvent object.
* @private
*/
onClick_(index, event) {
let button = event.target;
// Remove 'focused' state from the clicked button.
while (button && !button.classList.contains('breadcrumb-path')) {
button = button.parentElement;
}
if (button) {
button.blur();
}
this.navigateToIndex_(index);
}
} }
/** /**
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment