// 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.
/**
* @fileoverview xf-select element which is ChromeOS <select>..</select>.
*/
import type {CrActionMenuElement} from 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';
import {AnchorAlignment} from 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';
import type {CrButtonElement} from 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import {getCrActionMenuTop} from '../common/js/dom_utils.js';
import {css, type CSSResultGroup, customElement, html, property, query, XfBase} from './xf_base.js';
/**
* The data structure used to set the new options on the select element.
* The value field should be independent of the user locale, while text should
* be a string corresponding to the value in the user locale.
*/
export interface XfOption {
// The locale independent value, e.g., 'home'.
value: string;
// The locale dependent string, e.g., 'hejmen' in Esperanto.
text: string;
// Whether this is the default option, shown prior to user actions.
default?: boolean;
}
/**
* The data structure used to inform SELECTION_CHANGED listeners about the
* current selection. Posted in the event detail.
*/
export interface XfSelectedValue {
index: number;
value: string;
text: string;
}
/**
* Implements an element similar to HTML select, customized for ChromeOS files.
*
* It emits the `SELECTION_CHANGED` event when the selected element change. The
* detail filed of the event carries index of the new element, its value and the
* text visible to the user.
*
* const element = document.createElement('xf-select');
* element.options = [
* {value: 'value-a', text: 'Text of value A'},
* ...
* ];
* element.icon = 'select-location';
* element.addEventListener(
* SELECTION_CHANGED, (event) => {
* if (event.detail.value === 'value-a') {
* ... // React to value-a being selected.
* }
* });
*
*/
@customElement('xf-select')
export class XfSelect extends XfBase {
/**
* The name of the icon to be used by the xf-select. This icon name must match
* the name of an icon in the
* //ui/file_manager/file_manager/foreground/images/files/ui/
*/
@property({type: String, reflect: false}) icon = '';
/**
* The options available for selection.
*/
@property({type: Array, reflect: false}) options: XfOption[] = [];
/**
* The current selected value.
*/
@property({type: String, reflect: true}) value: string = '';
/**
* The alignment of items in the dropdown menu. Can be one of
* 'start', 'center', 'end'.
*/
@property({type: String, reflect: true}) menuAlignment: string = 'center';
static get events() {
return {
/** emits when the currently selected option changed. */
SELECTION_CHANGED: 'selection_changed',
} as const;
}
/**
* The button that toggles the options menu.
*/
@query('cr-button#dropdown-toggle')
private $toggleDropdownButton_?: CrButtonElement;
/**
* The options menu.
*/
@query('cr-action-menu') private $optionsMenu_?: CrActionMenuElement;
/**
* The currently selected option.
*/
private selectedOption_: XfSelectedValue = {
index: -1,
value: '',
text: '',
};
static override get styles(): CSSResultGroup {
return getCSS();
}
override render() {
const selectedIndex = this.computeSelectedIndex_();
return html`
${this.renderFilterChip_(selectedIndex)}
${this.renderDropdown_()}
`;
}
override click() {
if (this.$toggleDropdownButton_) {
this.$toggleDropdownButton_.click();
}
}
/**
* Returns whether the component is expanded, with options visible, or
* collapsed.
*/
get expanded(): boolean {
return this.$optionsMenu_ ? this.$optionsMenu_.open : false;
}
/**
* Returns a template of the chip that shows the currently selected filter
* value.
*/
private renderFilterChip_(selectedIndex: number) {
const buttonLabel =
selectedIndex === -1 ? '' : this.options[selectedIndex]!.text;
const iconPart = this.icon ?
html`<span id="xf-select-icon" class="xf-select-icon ${
this.icon}"></span>` :
html``;
const labelPart = html`<span id="selected-option">${buttonLabel}</span>`;
return html`
<cr-button id="dropdown-toggle"
aria-haspopup="menu"
aria-expanded=${this.expanded}
@click=${this.onToggleOptions_}>
${iconPart}${labelPart}<span id="dropdown-icon"></span>
</cr-button>`;
}
/**
* Returns a template of the dropdown which shows available choices.
*/
private renderDropdown_() {
const alignment = this.menuAlignment || 'center';
return html`<cr-action-menu>
${this.options.map((option, index) => {
const checked = this.selectedOption_!.value === option.value;
return html`
<cr-button
class="dropdown-item dropdown-item-${alignment}"
role="menuitemcheckbox"
aria-label="${option.text}"
aria-checked="${checked}"
@click=${() => this.onOptionSelected_(index)}
?selected=${checked}>
${option.text}
<div class='dropdown-filler'></div>
<div slot='suffix-icon' class='selected-icon'></div>
</cr-button>`;
})}
</cr-action-menu>`;
}
override updated(changedProperties: Map<string, any>) {
if (changedProperties.has('value')) {
this.updateSelectedOption_(this.computeIndexForValue_(this.value));
}
if (changedProperties.has('options')) {
this.updateSelectedOption_(this.computeSelectedIndex_());
}
}
/**
* Attempts to find the index of the value among options.
*/
private computeIndexForValue_(value: string|null): number {
let selectedIndex = -1;
if (value) {
selectedIndex = this.options.findIndex(e => e.value === this.value);
}
return selectedIndex;
}
/**
* If the index is within range of option list, updates the selected value to
* the one at the given index.
*/
private updateSelectedOption_(index: number) {
if (index !== this.selectedOption_.index) {
if (index >= 0 && index < this.options.length) {
this.selectedOption_ = {
index: index,
value: this.options[index]!.value,
text: this.options[index]!.text,
};
this.dispatchSelectionChanged_();
}
}
}
/**
* Attempts to establish the index of the selected item. The priority is given
* the the value attribute. If set, it decides which option is selected. If
* not set we pick either the first option, or the option with the default set
* to true.
*/
private computeSelectedIndex_(): number {
let selectedIndex = this.computeIndexForValue_(this.value);
// If we could not match the value, look for the default option.
if (selectedIndex === -1) {
for (let i = this.options.length - 1; i >= 0; --i) {
if (this.options[i]!.default) {
selectedIndex = i;
break;
}
}
}
if (selectedIndex === -1 && this.options.length > 0) {
selectedIndex = 0;
}
this.updateSelectedOption_(selectedIndex);
return selectedIndex;
}
/**
* Invoked when the toggle button is clicked. Toggles the visibility of the
* dropdown options.
*/
private onToggleOptions_(): void {
if (this.expanded) {
this.closeOptions_();
} else {
this.openOptions_();
}
}
/**
* Opens the dropdown options, providing they were closed.
*/
private openOptions_() {
if (!this.expanded) {
const element: HTMLElement = this.$toggleDropdownButton_!;
const top = getCrActionMenuTop(element, 8);
this.$optionsMenu_!.showAt(
element, {top: top, anchorAlignmentX: AnchorAlignment.AFTER_START});
}
}
/**
* Closes the dropdown options, providing they were open.
*/
private closeOptions_() {
if (this.expanded) {
this.$optionsMenu_!.close();
}
}
/**
* React to one of the options being selected. If the selection changed the
* currently selected option, it updates the value, which prompts
* re-rendering. It also posts a selection change event. Finally it always
* closes the option, regardless of change.
*/
private onOptionSelected_(index: number) {
if (index !== this.selectedOption_.index) {
this.updateSelectedOption_(index);
this.value = this.selectedOption_.value;
}
this.closeOptions_();
}
/**
* Returns the currently selected option. If nothing is selected the index is
* set to -1, and text and value are set to an empty string.
*/
getSelectedOption(): XfSelectedValue {
return this.selectedOption_;
}
/**
* Dispatches SELECTION_CHANGED event with the current value of the selected
* options.
*/
private dispatchSelectionChanged_(): void {
this.dispatchEvent(new CustomEvent(XfSelect.events.SELECTION_CHANGED, {
bubbles: true,
composed: true,
detail: this.selectedOption_,
}));
}
}
/**
* CSS used by the xf-select widget.
*/
function getCSS(): CSSResultGroup {
return css`
cr-button {
--active-bg: none;
--hover-bg-color: var(--cros-sys-hover_on_subtle);
--hover-border-color: var(--cros-sys-separator);
--ink-color: var(--cros-sys-ripple_neutral_on_subtle);
--ripple-opacity: 100%;
--text-color: var(--cros-sys-on_surface);
box-shadow: none;
font: var(--cros-button-1-font);
}
#dropdown-toggle {
--border-color: var(--cros-sys-separator);
--cr-button-height: 32px;
border-radius: 8px;
margin-inline: 4px;
min-width: auto;
padding-inline: 12px;
white-space: nowrap;
}
:host(:first-of-type) #dropdown-toggle {
margin-inline-start: 0;
}
:host-context(.focus-outline-visible) #dropdown-toggle:focus {
outline: 2px solid var(--cros-sys-focus_ring);
outline-offset: 2px;
}
.xf-select-icon {
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: var(--cros-sys-on_surface);
height: 20px;
width: 20px;
margin-inline: 0 8px;
}
#xf-select-icon.select-location {
-webkit-mask-image:
url(/foreground/images/files/ui/select_location.svg);
}
#xf-select-icon.select-time {
-webkit-mask-image:
url(/foreground/images/files/ui/select_time.svg);
}
#xf-select-icon.select-filetype {
-webkit-mask-image:
url(/foreground/images/files/ui/select_filetype.svg);
}
#dropdown-icon {
-webkit-mask-image:
url(/foreground/images/files/ui/xf_select_dropdown.svg);
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: var(--cros-sys-on_surface);
height: 20px;
width: 20px;
margin-inline: 8px 0;
}
cr-button.dropdown-item {
--focus-shadow-color: none;
font: var(--cros-button-2-font);
height: 36px;
padding: 0 16px;
}
cr-button.dropdown-item:hover {
background-color: var(--cros-sys-hover_on_subtle);
}
cr-button.dropdown-item-center {
justify-content: center;
}
cr-button.dropdown-item-start {
justify-content: start;
}
cr-button.dropdown-item-end {
justify-content: end;
}
div.dropdown-filler {
flex-grow: 1;
}
div.selected-icon {
-webkit-mask-image: url(/foreground/images/common/ic_selected.svg);
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: var(--cros-sys-primary);
height: 20px;
width: 20px;
visibility: hidden;
}
cr-button[selected] div.selected-icon {
visibility: visible;
}
:host-context(.focus-outline-visible)
cr-action-menu cr-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) cr-action-menu cr-button:hover {
background-color: unset;
}
cr-action-menu {
--cr-menu-background-color: var(--cros-sys-base_elevated);
--cr-menu-background-focus-color: none;
--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);
}
cr-action-menu::part(dialog) {
border-radius: 8px;
}
`;
}
/**
* A custom event that informs the container which option kind change to what
* value. It is up to the container to interpret these.
*/
export type SelectionChangedEvent = CustomEvent<XfSelectedValue>;
declare global {
interface HTMLElementEventMap {
[XfSelect.events.SELECTION_CHANGED]: SelectionChangedEvent;
}
interface HTMLElementTagNameMap {
'xf-select': XfSelect;
}
}