blob: 743497fbafcdc021d27e1e9cee31556933bce320 [file] [log] [blame]
// 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 !important;
}
:host-context(html.col-resize) > * {
cursor: unset !important;
}
:host {
display: flex;
font-family: 'Roboto Medium';
font-size: 14px;
outline: none;
overflow: hidden;
user-select: none;
white-space: nowrap;
align-items: center;
}
span[caret] {
-webkit-mask-image: url(../../images/files/ui/arrow_right.svg);
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: currentColor;
display: inline-flex;
height: 20px;
padding: 8px 0;
min-width: 20px;
width: 20px;
}
:host-context(html[dir='rtl']) span[caret] {
transform: scaleX(-1);
}
button {
/* don't use browser's background-color. */
background-color: unset;
border: 1px solid transparent;
border-radius: 4px;
color: var(--google-grey-700);
cursor: pointer;
display: inline-block;
/* don't use browser's button font. */
font: inherit;
height: 32px;
margin: 0;
/* text rendering debounce: fix a minimum width. */
min-width: calc(12px + 1em);
/* elide wide text */
max-width: 200px;
outline: none;
overflow: hidden;
padding: 0px 8px;
/* text rendering debounce: center. */
text-align: center;
text-overflow: ellipsis;
}
button[disabled] {
color: var(--google-grey-900);
cursor: default;
font-weight: 500;
margin-inline-end: 4px;
}
span[elider] {
--tap-target-shift: -7px;
-webkit-mask-image: url(../../images/files/ui/menu_ng.svg);
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: currentColor;
height: 48px;
margin-top: var(--tap-target-shift);
margin-inline-start: var(--tap-target-shift);
min-width: 48px;
position: relative;
transform: rotate(90deg);
width: 48px;
}
button[elider] {
border-radius: 50%;
box-sizing: border-box;
display: inline-flex;
height: 36px;
min-width: 36px;
padding: 0;
width: 36px;
}
button.dropdown-item {
position: relative;
}
:host-context(:root.pointer-active) button.dropdown-item:active {
background-color: rgba(0, 0, 0, 4%);
}
:host-context(:root:not(.pointer-active)) button.dropdown-item > paper-ripple {
display: none;
}
button.dropdown-item > paper-ripple {
--paper-ripple-opacity: 8%;
color: black;
}
button:not([disabled]):not(:active):hover {
background-color: rgba(0, 0, 0, 4%);
}
:host-context(:root.pointer-active) button:not(:active):hover {
background-color: unset;
cursor: default;
}
:host-context(:root.focus-outline-visible) > button:focus {
background-color: unset;
border: 1px solid var(--google-blue-600);
}
:host([checked]) button[elider] {
background-color: rgba(0, 0, 0, 12%);
}
button:active {
background-color: rgba(0, 0, 0, 12%);
}
#elider-menu button {
border: unset;
color: rgb(51, 51, 51);
display: block;
font-family: 'Roboto';
font-size: 13px;
min-width: 192px; /* menu width */
max-width: min(288px, 40vw);
padding: 0 16px;
text-align: start;
}
:host-context(:root.focus-outline-visible) #elider-menu button:hover {
background-color: unset;
}
:host-context(:root.focus-outline-visible) #elider-menu button:focus {
background-color: rgba(0, 0, 0, 4%);
}
</style>
<button id='first'></button>
<span caret hidden></span>
<button elider aria-haspopup='menu' aria-expanded='false'
aria-label='$i18n{LOCATION_BREADCRUMB_ELIDER_BUTTON_LABEL}'>
<span elider></span>
</button>
<span caret hidden></span>
<button id='second'></button>
<span caret hidden></span>
<button id='third'></button>
<span caret hidden></span>
<button id='fourth'></button>
<cr-action-menu id='elider-menu'></cr-menu-item>
`;
/**
* 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.addEventListener('tabkeyclose', this.onTabkeyClose_.bind(this));
this.addEventListener('close', this.onblur);
}
/**
* Gets parts.
* @return {!Array<string>}
*/
get parts() {
return this.parts_;
}
/**
* Gets 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) {
const previousSibling = buttons[i].previousElementSibling;
if (previousSibling.hasAttribute('caret')) {
previousSibling.hidden = !text;
}
buttons[i].removeAttribute('has-tooltip');
buttons[i].textContent = text;
buttons[i].hidden = !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.closeMenu_();
this.renderElidedParts_();
this.setAttribute('path', this.path);
this.signal_('path-rendered');
}
/**
* Renders elided path parts in a drop-down menu.
*
* @private
*/
renderElidedParts_() {
const elider = this.shadowRoot.querySelector('button[elider]');
const parts = this.parts_;
elider.hidden = parts.length <= 4;
if (elider.hidden) {
this.shadowRoot.querySelector('cr-action-menu').innerHTML = '';
elider.previousElementSibling.hidden = true;
return;
}
let elidedParts = '';
for (let i = 1; i < parts.length - 2; ++i) {
elidedParts += `<button class='dropdown-item'>${
parts[i]}<paper-ripple></paper-ripple></button>`;
}
const menu = this.shadowRoot.querySelector('cr-action-menu');
menu.innerHTML = elidedParts;
elider.previousElementSibling.hidden = false;
elider.hidden = false;
}
/**
* Returns the breadcrumb buttons: they contain the current path ordered by
* its parts, which are stored in the <button>.textContent.
*
* @return {!Array<HTMLButtonElement>}
* @private
*/
getBreadcrumbButtons_() {
const parts = this.shadowRoot.querySelectorAll('button[id]:not([hidden])');
if (this.parts_.length <= 4) {
return Array.from(parts);
}
const elided = this.shadowRoot.querySelectorAll('cr-action-menu button');
return [parts[0]].concat(Array.from(elided), Array.from(parts).slice(1));
}
/**
* 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 this.getBreadcrumbButtons_().filter(button => {
if (!button.hasAttribute('has-tooltip') && button.offsetWidth) {
return button.offsetWidth < button.scrollWidth;
}
});
}
/**
* Returns breadcrumb buttons that have a 'has-tooltip' attribute. Note the
* elider button is excluded since it has an i18n aria-label.
*
* @return {!Array<HTMLButtonElement>} buttons Caller could remove the tool
* tip event listeners from the returned buttons.
*/
getToolTipButtons() {
const hasToolTip = 'button:not([elider])[has-tooltip]';
return Array.from(this.shadowRoot.querySelectorAll(hasToolTip));
}
/**
* Handles 'click' events.
*
* Emits an index signal on breadcumb button click: the index indicates the
* current path part that was clicked.
*
* @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;
}
if (element instanceof HTMLButtonElement) {
const parts = this.getBreadcrumbButtons_();
this.signal_(parts.indexOf(element));
}
}
/**
* Handles keyboard events.
*
* @param {Event} event
* @private
*/
onKeydown_(event) {
if (event.key === ' ' || event.key === 'Enter') {
this.onClicked_(event);
}
}
/**
* Handles the custom 'tabkeyclose' event, that indicates a 'Tab' key event
* has returned focus to button[elider] while closing its drop-down menu.
*
* Moves the focus to the left or right of the button[elider] based on that
* 'Tab' key event's shiftKey state. There is always a visible <button> to
* the left or right of button[elider].
*
* @param {Event} event
* @private
*/
onTabkeyClose_(event) {
if (!event.detail.shiftKey) {
this.shadowRoot.querySelector(':focus ~ button:not([hidden])').focus();
} else { // button#first is left of the button[elider].
this.shadowRoot.querySelector('#first').focus();
}
}
/**
* 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.closeMenu_();
return;
}
// Compute drop-down horizontal RTL/LTR position.
let position;
const elider = this.shadowRoot.querySelector('button[elider]');
if (document.documentElement.getAttribute('dir') === 'rtl') {
position = elider.offsetLeft + elider.offsetWidth;
position = document.documentElement.offsetWidth - position;
} else {
position = elider.offsetLeft;
}
// Show drop-down below the elider button.
const menu = this.shadowRoot.querySelector('cr-action-menu');
const top = elider.offsetTop + elider.offsetHeight + 8;
!window.UNIT_TEST && menu.showAt(elider, {top: top});
// Style drop-down and horizontal position.
const dialog = !window.UNIT_TEST ? menu.getDialog() : {style: {}};
dialog.style['left'] = position + 'px';
dialog.style['right'] = position + 'px';
dialog.style['overflow'] = 'hidden auto';
dialog.style['max-height'] = '272px';
// Update global <html> and |this| element state.
document.documentElement.classList.add('breadcrumb-elider-expanded');
elider.setAttribute('aria-expanded', 'true');
this.setAttribute('checked', '');
// Emit rendered signal.
this.signal_('path-rendered');
}
/**
* Closes drop-down menu if needed.
*
* @private
*/
closeMenu_() {
// Update global <html> and |this| element state.
document.documentElement.classList.remove('breadcrumb-elider-expanded');
const elider = this.shadowRoot.querySelector('button[elider]');
elider.setAttribute('aria-expanded', 'false');
this.removeAttribute('checked');
// Close the drop-down <dialog> if needed.
const menu = this.shadowRoot.querySelector('cr-action-menu');
if (!window.UNIT_TEST && menu.getDialog().hasAttribute('open')) {
menu.close();
}
}
}
customElements.define('bread-crumb', BreadCrumb);