// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';
import 'chrome://resources/polymer/v3_0/paper-ripple/paper-ripple.js';
import type {CrActionMenuElement} from 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';
import {getCrActionMenuTop, mouseEnterMaybeShowTooltip} from '../common/js/dom_utils.js';
import {str} from '../common/js/translations.js';
import {css, customElement, html, property, type PropertyValues, query, state, XfBase} from './xf_base.js';
/**
* Breadcrumb displays the current directory path.
*/
@customElement('xf-breadcrumb')
export class XfBreadcrumb extends XfBase {
/** A path is a "/" separated string. */
@property({type: String, reflect: true}) path = '';
/** The maximum number of path elements shown. */
@property({type: Number, reflect: true}) maxPathParts = 4;
static get events() {
return {
/** emits when any part of the breadcrumb is changed. */
BREADCRUMB_CLICKED: 'breadcrumb_clicked',
} as const;
}
/** Represents the parts extracted from the "path". */
get parts(): string[] {
return this.path.split('/');
}
@query('button[elider]') private $eliderButton_?: HTMLButtonElement;
@query('cr-action-menu') private $actionMenu_?: CrActionMenuElement;
@query('#first') private $firstButton_!: HTMLButtonElement;
/** Indicates if the elider menu is open or not. */
@state() private isMenuOpen_ = false;
static override get styles() {
return getCSS();
}
override render() {
if (!this.path) {
return html``;
}
const parts = this.path.split('/');
const showElider = parts.length > this.maxPathParts;
const partBeforeElider = parts[0];
const eliderParts = showElider ? parts.slice(1, parts.length - 2) : [];
const afterEliderIndex = showElider ? parts.length - 2 : 1;
const partsAfterElider = parts.slice(afterEliderIndex);
const ids = ['second', 'third', 'fourth'];
return html`
${this.renderButton_(0, 'first', partBeforeElider)}
${showElider ? this.renderElider_(1, eliderParts) : ''}
${
partsAfterElider.map(
(part, index) => html`${
this.renderButton_(
index + afterEliderIndex, ids[index]!, part)}`)}
`;
}
/** Renders the path <button> parts. */
private renderButton_(index: number, id: string, label: string|undefined) {
const parts = this.path.split('/');
const isLast = index === parts.length - 1;
const caret = isLast ? '' : html`<span caret></span>`;
return html`
<button
?disabled=${isLast}
id=${id}
@click=${(event: MouseEvent) => this.onButtonClicked_(index, event)}
@mouseenter=${this.onButtonMouseEntered_}
@keydown=${
(event: KeyboardEvent) => this.onButtonKeydown_(index, event)}
>${window.unescape(label || '')}<paper-ripple></paper-ripple></button>
${caret}
`;
}
/** Renders elided path parts in a drop-down menu. */
private renderElider_(startIndex: number, parts: string[]) {
return html`
<button
elider
aria-haspopup="menu"
aria-expanded=${this.isMenuOpen_ ? 'true' : 'false'}
aria-label=${str('LOCATION_BREADCRUMB_ELIDER_BUTTON_LABEL')}
@click=${this.onEliderButtonClicked_}
@keydown=${this.onEliderButtonKeydown_}
><span elider></span><paper-ripple></paper-ripple></button>
<span caret></span>
<cr-action-menu id="elider-menu">
${
parts.map(
(part, index) => html`
<button
class='dropdown-item'
@click=${
(event: MouseEvent) =>
this.onButtonClicked_(index + startIndex, event)}
@mouseenter=${this.onButtonMouseEntered_}
@keydown=${
(event: KeyboardEvent) =>
this.onButtonKeydown_(index + startIndex, event)}
>${window.unescape(part)}<paper-ripple></paper-ripple></button>
`)}
</cr-action-menu>
`;
}
override connectedCallback() {
super.connectedCallback();
this.addEventListener('tabkeyclose', this.onTabkeyClose_.bind(this));
this.addEventListener('close', this.closeMenu_.bind(this));
this.addEventListener('blur', this.closeMenu_.bind(this));
}
override willUpdate(changedProperties: PropertyValues) {
// If path changes, we also need to update the state (isMenuOpen_) before
// the next render.
if (changedProperties.has('path') && this.isMenuOpen_) {
this.closeMenu_();
}
}
/**
* Handles 'click' events for path button.
*
* Emits the `BREADCRUMB_CLICKED` event when a breadcrumb button is clicked
* with the index indicating the current path part that was clicked.
*/
private onButtonClicked_(index: number, event: MouseEvent|KeyboardEvent) {
event.stopImmediatePropagation();
event.preventDefault();
if ((event as KeyboardEvent).repeat) {
return;
}
const breadcrumbClickEvent =
new CustomEvent(XfBreadcrumb.events.BREADCRUMB_CLICKED, {
bubbles: true,
composed: true,
detail: {
partIndex: index,
},
});
this.dispatchEvent(breadcrumbClickEvent);
}
/** Handles mouseEnter event for the path button. */
private onButtonMouseEntered_(event: MouseEvent) {
mouseEnterMaybeShowTooltip(event);
}
/** Handles keyboard events for path button. */
private onButtonKeydown_(index: number, event: KeyboardEvent) {
if (event.key === ' ' || event.key === 'Enter') {
this.onButtonClicked_(index, event);
}
}
/**
* Handles 'click' events for elider button.
*/
private onEliderButtonClicked_(event: MouseEvent|KeyboardEvent) {
event.stopImmediatePropagation();
event.preventDefault();
if ((event as KeyboardEvent).repeat) {
return;
}
this.toggleMenu_();
}
/** Handles keyboard events for elider button. */
private onEliderButtonKeydown_(event: KeyboardEvent) {
if (event.key === ' ' || event.key === 'Enter') {
this.onEliderButtonClicked_(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].
*/
private onTabkeyClose_(event: Event) {
const detail = (event as CustomEvent).detail;
if (!detail.shiftKey) {
(this.renderRoot.querySelector(':focus ~ button') as HTMLElement).focus();
} else { // button#first is left of the button[elider].
this.$firstButton_.focus();
}
}
/**
* Toggles drop-down menu: opens if closed or closes if open via closeMenu_.
*/
private toggleMenu_() {
if (this.isMenuOpen_) {
this.closeMenu_();
return;
}
// Compute drop-down horizontal RTL/LTR position.
let position: number;
if (document.documentElement.getAttribute('dir') === 'rtl') {
position =
this.$eliderButton_!.offsetLeft + this.$eliderButton_!.offsetWidth;
position = document.documentElement.offsetWidth - position;
} else {
position = this.$eliderButton_!.offsetLeft;
}
// Show drop-down below the elider button.
const top = getCrActionMenuTop(this.$eliderButton_!, 8);
this.$actionMenu_!.showAt(this.$eliderButton_!, {top: top});
// Style drop-down and horizontal position.
const dialog = this.$actionMenu_!.getDialog();
dialog.style.left = position + 'px';
dialog.style.right = position + 'px';
dialog.style.overflow = 'hidden auto';
dialog.style.maxHeight = '272px';
// Update global <html> and |this| element state.
document.documentElement.classList.add('breadcrumb-elider-expanded');
this.isMenuOpen_ = true;
}
/** Closes drop-down menu if needed. */
private closeMenu_() {
// Update global <html> and |this| element state.
document.documentElement.classList.remove('breadcrumb-elider-expanded');
// Close the drop-down <dialog> if needed.
if (this.$actionMenu_?.getDialog().hasAttribute('open')) {
this.$actionMenu_.close();
}
this.isMenuOpen_ = false;
}
}
function getCSS() {
return css`
:host([hidden]),
[hidden] {
display: none !important;
}
:host-context(html.col-resize) > * {
cursor: unset !important;
}
:host {
align-items: center;
display: flex;
outline: none;
overflow: hidden;
padding-inline-start: 8px;
user-select: none;
white-space: nowrap;
}
span[caret] {
-webkit-mask-image: url(/foreground/images/files/ui/arrow_right.svg);
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: var(--cros-sys-on_surface_variant);
display: inline-flex;
height: 20px;
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: none;
color: var(--cros-sys-on_surface_variant);
cursor: pointer;
display: inline-block;
position: relative;
font: var(--cros-title-1-font);
margin: 0;
/* elide wide text */
max-width: 200px;
/* text rendering debounce: fix a minimum width. */
min-width: calc(12px + 1em);
outline: none;
overflow: hidden;
/* text rendering debounce: center. */
text-align: center;
text-overflow: ellipsis;
}
button[disabled] {
cursor: default;
margin-inline-end: 4px;
pointer-events: none;
}
span[elider] {
--tap-target-shift: -6px;
-webkit-mask-image: url(/foreground/images/files/ui/menu_ng.svg);
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: currentColor;
height: 48px;
margin-inline-start: var(--tap-target-shift);
margin-top: var(--tap-target-shift);
min-width: 48px;
position: relative;
transform: rotate(90deg);
width: 48px;
}
button[elider] {
border-radius: 50%;
display: inline-flex;
height: 36px;
min-width: 36px;
padding: 0;
width: 36px;
}
:host > button:not([elider]) {
border-radius: 18px;
height: 36px;
margin: 6px 2px;
padding: 0 12px;
}
:host > button:first-child {
margin-inline-start: 0;
}
button[disabled] {
color: var(--cros-sys-on_surface);
}
button:not(:active):hover {
background-color: var(--cros-sys-hover_on_subtle);
}
:host-context(.pointer-active) button:not(:active):hover {
background-color: unset;
cursor: default;
}
paper-ripple {
--paper-ripple-opacity: 100%;
color: var(--cros-sys-ripple_neutral_on_subtle);
}
:host > button:focus-visible {
outline: 2px solid var(--cros-sys-focus_ring);
}
button:active {
background-color: var(--cros-sys-hover_on_subtle);
}
button[elider][aria-expanded="true"] {
background-color: var(--cros-sys-pressed_on_subtle);
}
#elider-menu button {
color: var(--cros-sys-on_surface);
display: block;
font: var(--cros-button-2-font);
height: 36px;
max-width: min(288px, 40vw);
min-width: 192px; /* menu width */
padding: 0 16px;
position: relative;
text-align: start;
}
:host-context(.focus-outline-visible) #elider-menu button:focus::after {
border: 2px solid var(--cros-sys-focus_ring);
border-radius: 8px;
content: '';
height: 32px; /* option height - 2 x border width */
left: 0;
position: absolute;
top: 0;
width: calc(100% - 4px); /* 2 x border width */
}
/** Reset the hover color when using keyboard to navigate the menu items. */
:host-context(.focus-outline-visible) #elider-menu button:hover {
background-color: unset;
}
cr-action-menu {
--cr-menu-background-color: var(--cros-sys-base_elevated);
--cr-menu-background-sheen: none;
/* TODO(wenbojie): use elevation variable when it's ready.
--cros-sys-elevation3 */
--cr-menu-shadow: var(--cros-elevation-2-shadow);
}
`;
}
/**
* `partIndex` is the index of the breadcrumb path e.g.:
* "/My files/Downloads/sub-folder" indexes:
* 0 1 2
*/
export type BreadcrumbClickedEvent = CustomEvent<{partIndex: number}>;
declare global {
interface HTMLElementEventMap {
[XfBreadcrumb.events.BREADCRUMB_CLICKED]: BreadcrumbClickedEvent;
}
interface HTMLElementTagNameMap {
'xf-breadcrumb': XfBreadcrumb;
}
}