blob: b934116f41dce880e7bd7045ada47308c0d1ae84 [file] [log] [blame]
// Copyright 2015 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.
/**
* @fileoverview
* @suppress {uselessCode} Temporary suppress because of the line exporting.
*/
// clang-format off
// #import {A11yAnnounce} from './a11y_announce.m.js';
// #import {FileListSelectionModel, FileListSingleSelectionModel} from './file_list_selection_model.m.js';
// #import {EntryLocation} from '../../../../externs/entry_location.m.js';
// #import {MetadataModel} from '../metadata/metadata_model.m.js';
// #import {FilesAppEntry} from '../../../../externs/files_app_entry_interfaces.m.js';
// #import {ListItem} from 'chrome://resources/js/cr/ui/list_item.m.js';
// #import {ListSelectionModel} from 'chrome://resources/js/cr/ui/list_selection_model.m.js';
// #import {TableList} from './table/table_list.m.js';
// #import {FileTapHandler} from './file_tap_handler.m.js';
// #import {List} from 'chrome://resources/js/cr/ui/list.m.js';
// #import {FileType} from '../../../common/js/file_type.m.js';
// #import {util, str, strf} from '../../../common/js/util.m.js';
// #import {ListSelectionController} from 'chrome://resources/js/cr/ui/list_selection_controller.m.js';
// #import {isMac} from 'chrome://resources/js/cr.m.js';
// #import {assert} from 'chrome://resources/js/assert.m.js';
// clang-format on
/**
* Namespace for utility functions.
*/
const filelist = {};
/**
* File table list.
*/
/* #export */ class FileTableList extends cr.ui.table.TableList {
constructor() {
// To silence closure compiler.
super();
/*
* @type {?function(number, number)}
*/
this.onMergeItems_ = null;
throw new Error('Designed to decorate elements');
}
/**
* @param {function(number, number)} onMergeItems callback called from
* |mergeItems| with the parameters |beginIndex| and |endIndex|.
*/
setOnMergeItems(onMergeItems) {
assert(!this.onMergeItems_);
this.onMergeItems_ = onMergeItems;
}
/** @override */
mergeItems(beginIndex, endIndex) {
super.mergeItems(beginIndex, endIndex);
// Make sure that list item's selected attribute is updated just after the
// mergeItems operation is done. This prevents checkmarks on selected items
// from being animated unintentionally by redraw.
for (let i = beginIndex; i < endIndex; i++) {
const item = this.getListItemByIndex(i);
if (!item) {
continue;
}
const isSelected = this.selectionModel.getIndexSelected(i);
if (item.selected !== isSelected) {
item.selected = isSelected;
}
}
if (this.onMergeItems_) {
this.onMergeItems_(beginIndex, endIndex);
}
}
/** @override */
createSelectionController(sm) {
return new FileListSelectionController(assert(sm), this);
}
/** @return {A11yAnnounce} */
get a11y() {
return this.table.a11y;
}
/**
* @param {number} index Index of the list item.
* @return {string}
*/
getItemLabel(index) {
return this.table.getItemLabel(index);
}
}
/**
* Decorates TableList as FileTableList.
* @param {!cr.ui.table.TableList} self A tabel list element.
*/
FileTableList.decorate = self => {
self.__proto__ = FileTableList.prototype;
self.setAttribute('aria-multiselectable', true);
self.setAttribute('aria-describedby', 'more-actions-info');
/** @type {FileTableList} */ (self).onMergeItems_ = null;
};
/**
* Selection controller for the file table list.
*/
class FileListSelectionController extends cr.ui.ListSelectionController {
/**
* @param {!cr.ui.ListSelectionModel} selectionModel The selection model to
* interact with.
* @param {!FileTableList} tableList
*/
constructor(selectionModel, tableList) {
super(selectionModel);
/** @const @private {!FileTapHandler} */
this.tapHandler_ = new FileTapHandler();
/** @const @private {!FileTableList} */
this.tableList_ = tableList;
}
/** @override */
handlePointerDownUp(e, index) {
filelist.handlePointerDownUp.call(this, e, index);
}
/** @override */
handleTouchEvents(e, index) {
if (this.tapHandler_.handleTouchEvents(
assert(e), index, filelist.handleTap.bind(this))) {
// If a tap event is processed, FileTapHandler cancels the event to
// prevent triggering click events. Then it results not moving the focus
// to the list. So we do that here explicitly.
filelist.focusParentList(e);
}
}
/** @override */
handleKeyDown(e) {
filelist.handleKeyDown.call(this, e);
}
/** @return {!FileTableList} */
get filesView() {
return this.tableList_;
}
}
/**
* Common item decoration for table's and grid's items.
* @param {cr.ui.ListItem} li List item.
* @param {Entry|FilesAppEntry} entry The entry.
* @param {!MetadataModel} metadataModel Cache to
* retrieve metadada.
*/
filelist.decorateListItem = (li, entry, metadataModel) => {
li.classList.add(entry.isDirectory ? 'directory' : 'file');
// The metadata may not yet be ready. In that case, the list item will be
// updated when the metadata is ready via updateListItemsMetadata. For files
// not on an external backend, externalProps is not available.
const externalProps = metadataModel.getCache([entry], [
'hosted', 'availableOffline', 'customIconUrl', 'shared', 'isMachineRoot',
'isExternalMedia', 'pinned'
])[0];
filelist.updateListItemExternalProps(
li, externalProps, util.isTeamDriveRoot(entry));
// Overriding the default role 'list' to 'listbox' for better
// accessibility on ChromeOS.
li.setAttribute('role', 'option');
Object.defineProperty(li, 'selected', {
/**
* @this {cr.ui.ListItem}
* @return {boolean} True if the list item is selected.
*/
get: function() {
return this.hasAttribute('selected');
},
/**
* @this {cr.ui.ListItem}
*/
set: function(v) {
if (v) {
this.setAttribute('selected', '');
} else {
this.removeAttribute('selected');
}
}
});
};
/**
* Render the type column of the detail table.
* @param {!Document} doc Owner document.
* @param {!Entry} entry The Entry object to render.
* @param {EntryLocation} locationInfo
* @param {string=} opt_mimeType Optional mime type for the file.
* @return {!HTMLDivElement} Created element.
*/
filelist.renderFileTypeIcon = (doc, entry, locationInfo, opt_mimeType) => {
const icon = /** @type {!HTMLDivElement} */ (doc.createElement('div'));
icon.className = 'detail-icon';
icon.setAttribute(
'file-type-icon',
FileType.getIcon(entry, opt_mimeType, locationInfo.rootType));
return icon;
};
/**
* Render filename label for grid and list view.
* @param {!Document} doc Owner document.
* @param {!Entry|!FilesAppEntry} entry The Entry object to render.
* @param {EntryLocation} locationInfo
* @return {!HTMLDivElement} The label.
*/
filelist.renderFileNameLabel = (doc, entry, locationInfo) => {
// Filename need to be in a '.filename-label' container for correct
// work of inplace renaming.
const box = /** @type {!HTMLDivElement} */ (doc.createElement('div'));
box.className = 'filename-label';
const fileName = doc.createElement('span');
fileName.className = 'entry-name';
fileName.textContent = util.getEntryLabel(locationInfo, entry);
box.appendChild(fileName);
return box;
};
/**
* Renders the Drive pinned marker in the detail table.
* @return {!HTMLDivElement} Created element.
*/
filelist.renderPinned = (doc) => {
const icon = /** @type {!HTMLDivElement} */ (doc.createElement('div'));
icon.className = 'detail-pinned';
icon.setAttribute('aria-label', str('OFFLINE_COLUMN_LABEL'));
return icon;
};
/**
* Updates grid item or table row for the externalProps.
* @param {cr.ui.ListItem} li List item.
* @param {Object} externalProps Metadata.
*/
filelist.updateListItemExternalProps = (li, externalProps, isTeamDriveRoot) => {
if (li.classList.contains('file')) {
li.classList.toggle(
'dim-offline', externalProps.availableOffline === false);
li.classList.toggle('dim-hosted', !!externalProps.hosted);
}
li.classList.toggle('pinned', !!externalProps.pinned);
const iconDiv = li.querySelector('.detail-icon');
if (!iconDiv) {
return;
}
if (externalProps.customIconUrl) {
iconDiv.style.backgroundImage = 'url(' + externalProps.customIconUrl + ')';
} else {
iconDiv.style.backgroundImage = ''; // Back to the default image.
}
if (li.classList.contains('directory')) {
iconDiv.classList.toggle('shared', !!externalProps.shared);
iconDiv.classList.toggle('team-drive-root', !!isTeamDriveRoot);
iconDiv.classList.toggle('computers-root', !!externalProps.isMachineRoot);
iconDiv.classList.toggle(
'external-media-root', !!externalProps.isExternalMedia);
}
};
/**
* Handles tap events on file list to change the selection state.
*
* @param {!Event} e The browser mouse event.
* @param {number} index The index that was under the mouse pointer, -1 if
* none.
* @param {!FileTapHandler.TapEvent} eventType
* @return True if conducted any action. False when if did nothing special for
* tap.
* @this {cr.ui.ListSelectionController} either FileListSelectionController or
* FileGridSelectionController.
*/
filelist.handleTap = function(e, index, eventType) {
const sm = /**
@type {!FileListSelectionModel|!FileListSingleSelectionModel}
*/
(this.selectionModel);
if (eventType === FileTapHandler.TapEvent.TWO_FINGER_TAP) {
// Prepare to open the context menu in the same manner as the right click.
// If the target is any of the selected files, open a one for those files.
// If the target is a non-selected file, cancel current selection and open
// context menu for the single file.
// Otherwise (when the target is the background), for the current folder.
if (index === -1) {
// Two-finger tap outside the list should be handled here because it does
// not produce mousedown/click events.
this.filesView.a11y.speakA11yMessage(str('SELECTION_ALL_ENTRIES'));
sm.unselectAll();
} else {
const indexSelected = sm.getIndexSelected(index);
if (!indexSelected) {
// Prepare to open context menu of the new item by selecting only it.
if (sm.getCheckSelectMode()) {
// Unselect all items once to ensure that the check-select mode is
// terminated.
sm.unselectAll();
}
sm.beginChange();
sm.selectedIndex = index;
sm.endChange();
const name = this.filesView.getItemLabel(index);
}
}
// Context menu will be opened for the selected files by the following
// 'contextmenu' event.
return false;
}
if (index === -1) {
return false;
}
// Single finger tap.
const isTap = eventType === FileTapHandler.TapEvent.TAP ||
eventType === FileTapHandler.TapEvent.LONG_TAP;
// Revert to click handling for single tap on checkbox or tap during rename.
// Single tap on the checkbox in the list view mode should toggle select.
// Single tap on input for rename should focus on input.
const isCheckbox = e.target.classList.contains('detail-checkmark');
const isRename = e.target.localName === 'input';
if (eventType === FileTapHandler.TapEvent.TAP && (isCheckbox || isRename)) {
return false;
}
if (sm.multiple && sm.getCheckSelectMode() && isTap && !e.shiftKey) {
// toggle item selection. Equivalent to mouse click on checkbox.
sm.beginChange();
const name = this.filesView.getItemLabel(index);
const msgId = sm.getIndexSelected(index) ? 'SELECTION_ADD_SINGLE_ENTRY' :
'SELECTION_REMOVE_SINGLE_ENTRY';
this.filesView.a11y.speakA11yMessage(strf(msgId, name));
sm.setIndexSelected(index, !sm.getIndexSelected(index));
// Toggle the current one and make it anchor index.
sm.leadIndex = index;
sm.anchorIndex = index;
sm.endChange();
return true;
} else if (
sm.multiple && (eventType === FileTapHandler.TapEvent.LONG_PRESS)) {
sm.beginChange();
if (!sm.getCheckSelectMode()) {
// Make sure to unselect the leading item that was not the touch target.
sm.unselectAll();
sm.setCheckSelectMode(true);
}
const name = this.filesView.getItemLabel(index);
sm.setIndexSelected(index, true);
sm.leadIndex = index;
sm.anchorIndex = index;
sm.endChange();
return true;
// Do not toggle selection yet, so as to avoid unselecting before drag.
} else if (
eventType === FileTapHandler.TapEvent.TAP && !sm.getCheckSelectMode()) {
// Single tap should open the item with default action.
// Select the item, so that MainWindowComponent will execute action of it.
sm.beginChange();
sm.unselectAll();
const name = this.filesView.getItemLabel(index);
sm.setIndexSelected(index, true);
sm.leadIndex = index;
sm.anchorIndex = index;
sm.endChange();
}
return false;
};
/**
* Handles mouseup/mousedown events on file list to change the selection state.
*
* Basically the content of this function is identical to
* cr.ui.ListSelectionController's handlePointerDownUp(), but following
* handlings are inserted to control the check-select mode.
*
* 1) When checkmark area is clicked, toggle item selection and enable the
* check-select mode.
* 2) When non-checkmark area is clicked in check-select mode, disable the
* check-select mode.
*
* @param {!Event} e The browser mouse event.
* @param {number} index The index that was under the mouse pointer, -1 if
* none.
* @this {cr.ui.ListSelectionController} either FileListSelectionController or
* FileGridSelectionController.
*/
filelist.handlePointerDownUp = function(e, index) {
const sm = /**
@type {!FileListSelectionModel|!FileListSingleSelectionModel}
*/
(this.selectionModel);
const anchorIndex = sm.anchorIndex;
const isDown = (e.type === 'mousedown');
const isTargetCheckmark = e.target.classList.contains('detail-checkmark') ||
e.target.classList.contains('checkmark');
// If multiple selection is allowed and the checkmark is clicked without
// modifiers(Ctrl/Shift), the click should toggle the item's selection.
// (i.e. same behavior as Ctrl+Click)
const isClickOnCheckmark =
(isTargetCheckmark && sm.multiple && index !== -1 && !e.shiftKey &&
!e.ctrlKey && e.button === 0);
sm.beginChange();
if (index === -1) {
this.filesView.a11y.speakA11yMessage(str('SELECTION_CANCELLATION'));
sm.leadIndex = sm.anchorIndex = -1;
sm.unselectAll();
} else {
if (sm.multiple && (e.ctrlKey || isClickOnCheckmark) && !e.shiftKey) {
// Selection is handled at mouseUp.
if (!isDown) {
// 1) When checkmark area is clicked, toggle item selection and enable
// the check-select mode.
if (isClickOnCheckmark) {
// If Files app enters check-select mode by clicking an item's icon,
// existing selection should be cleared.
if (!sm.getCheckSelectMode()) {
sm.unselectAll();
}
}
// Always enables check-select mode when the selection is updated by
// Ctrl+Click or Click on an item's icon.
sm.setCheckSelectMode(true);
// Toggle the current one and make it anchor index.
const name = this.filesView.getItemLabel(index);
const msgId = sm.getIndexSelected(index) ?
'SELECTION_REMOVE_SINGLE_ENTRY' :
'SELECTION_ADD_SINGLE_ENTRY';
this.filesView.a11y.speakA11yMessage(strf(msgId, name));
sm.setIndexSelected(index, !sm.getIndexSelected(index));
sm.leadIndex = index;
sm.anchorIndex = index;
}
} else if (e.shiftKey && anchorIndex !== -1 && anchorIndex !== index) {
// Shift is done in mousedown.
if (isDown) {
sm.unselectAll();
sm.leadIndex = index;
if (sm.multiple) {
sm.selectRange(anchorIndex, index);
const nameStart = this.filesView.getItemLabel(anchorIndex);
const nameEnd = this.filesView.getItemLabel(index);
const count = Math.abs(index - anchorIndex) + 1;
const msg = strf('SELECTION_ADD_RANGE', count, nameStart, nameEnd);
this.filesView.a11y.speakA11yMessage(msg);
} else {
const name = this.filesView.getItemLabel(index);
sm.setIndexSelected(index, true);
}
}
} else {
// Right click for a context menu needs to not clear the selection.
const isRightClick = e.button === 2;
// If the index is selected this is handled in mouseup.
const indexSelected = sm.getIndexSelected(index);
if ((indexSelected && !isDown || !indexSelected && isDown) &&
!(indexSelected && isRightClick)) {
// 2) When non-checkmark area is clicked in check-select mode, disable
// the check-select mode.
if (sm.getCheckSelectMode()) {
// Unselect all items once to ensure that the check-select mode is
// terminated.
sm.endChange();
sm.unselectAll();
sm.beginChange();
}
// This event handler is called for mouseup and mousedown, let's
// announce the selection only in one of them.
if (isDown) {
const name = this.filesView.getItemLabel(index);
}
sm.selectedIndex = index;
}
}
}
sm.endChange();
};
/**
* Handles key events on file list to change the selection state.
*
* Basically the content of this function is identical to
* cr.ui.ListSelectionController's handleKeyDown(), but following handlings is
* inserted to control the check-select mode.
*
* 1) When pressing direction key results in a single selection, the
* check-select mode should be terminated.
*
* @param {Event} e The keydown event.
* @this {cr.ui.ListSelectionController} either FileListSelectionController or
* FileGridSelectionController.
*/
filelist.handleKeyDown = function(e) {
const tagName = e.target.tagName;
// If focus is in an input field of some kind, only handle navigation keys
// that aren't likely to conflict with input interaction (e.g., text
// editing, or changing the value of a checkbox or select).
if (tagName === 'INPUT') {
const inputType = e.target.type;
// Just protect space (for toggling) for checkbox and radio.
if (inputType === 'checkbox' || inputType === 'radio') {
if (e.key === ' ') {
return;
}
// Protect all but the most basic navigation commands in anything else.
} else if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') {
return;
}
}
// Similarly, don't interfere with select element handling.
if (tagName === 'SELECT') {
return;
}
const sm = /**
@type {!FileListSelectionModel|!FileListSingleSelectionModel}
*/
(this.selectionModel);
let newIndex = -1;
const leadIndex = sm.leadIndex;
let prevent = true;
// Ctrl/Meta+A. Use keyCode=65 to use the same shortcut key regardless of
// keyboard layout.
const pressedKeyA = e.keyCode === 65 || e.key === 'a';
if (sm.multiple && pressedKeyA &&
(cr.isMac && e.metaKey || !cr.isMac && e.ctrlKey)) {
this.filesView.a11y.speakA11yMessage(str('SELECTION_ALL_ENTRIES'));
sm.setCheckSelectMode(true);
sm.selectAll();
e.preventDefault();
return;
}
// Esc
if (e.key === 'Escape' && !e.ctrlKey && !e.shiftKey) {
this.filesView.a11y.speakA11yMessage(str('SELECTION_CANCELLATION'));
sm.unselectAll();
e.preventDefault();
return;
}
// Space: Note ChromeOS and ChromeOS on Linux can generate KeyDown Space
// events differently the |key| attribute might be set to 'Unidentified'.
if (e.code === 'Space' || e.key === ' ') {
if (leadIndex !== -1) {
const selected = sm.getIndexSelected(leadIndex);
if (e.ctrlKey) {
sm.beginChange();
// Force selecting if it's the first item selected, otherwise flip the
// "selected" status.
if (selected && sm.selectedIndexes.length === 1 &&
!sm.getCheckSelectMode()) {
// It needs to go back/forth to trigger the 'change' event.
sm.setIndexSelected(leadIndex, false);
sm.setIndexSelected(leadIndex, true);
const name = this.filesView.getItemLabel(leadIndex);
this.filesView.a11y.speakA11yMessage(
strf('SELECTION_SINGLE_ENTRY', name));
} else {
// Toggle the current one and make it anchor index.
sm.setIndexSelected(leadIndex, !selected);
const name = this.filesView.getItemLabel(leadIndex);
const msgId = selected ? 'SELECTION_REMOVE_SINGLE_ENTRY' :
'SELECTION_ADD_SINGLE_ENTRY';
this.filesView.a11y.speakA11yMessage(strf(msgId, name));
}
// Force check-select, FileListSelectionModel.onChangeEvent_ resets it
// if needed.
sm.setCheckSelectMode(true);
sm.endChange();
// Prevents space to opening quickview.
e.stopPropagation();
e.preventDefault();
return;
}
}
}
switch (e.key) {
case 'Home':
newIndex = this.getFirstIndex();
break;
case 'End':
newIndex = this.getLastIndex();
break;
case 'ArrowUp':
newIndex = leadIndex === -1 ? this.getLastIndex() :
this.getIndexAbove(leadIndex);
break;
case 'ArrowDown':
newIndex = leadIndex === -1 ? this.getFirstIndex() :
this.getIndexBelow(leadIndex);
break;
case 'ArrowLeft':
case 'MediaTrackPrevious':
newIndex = leadIndex === -1 ? this.getLastIndex() :
this.getIndexBefore(leadIndex);
break;
case 'ArrowRight':
case 'MediaTrackNext':
newIndex = leadIndex === -1 ? this.getFirstIndex() :
this.getIndexAfter(leadIndex);
break;
default:
prevent = false;
}
if (newIndex >= 0 && newIndex < sm.length) {
sm.beginChange();
sm.leadIndex = newIndex;
if (e.shiftKey) {
const anchorIndex = sm.anchorIndex;
if (sm.multiple) {
sm.unselectAll();
}
if (anchorIndex === -1) {
const name = this.filesView.getItemLabel(newIndex);
sm.setIndexSelected(newIndex, true);
sm.anchorIndex = newIndex;
} else {
const nameStart = this.filesView.getItemLabel(anchorIndex);
const nameEnd = this.filesView.getItemLabel(newIndex);
const count = Math.abs(newIndex - anchorIndex) + 1;
const msg = strf('SELECTION_ADD_RANGE', count, nameStart, nameEnd);
this.filesView.a11y.speakA11yMessage(msg);
sm.selectRange(anchorIndex, newIndex);
}
} else if (e.ctrlKey) {
// While Ctrl is being held, only leadIndex and anchorIndex are moved.
sm.anchorIndex = newIndex;
} else {
// 1) When pressing direction key results in a single selection, the
// check-select mode should be terminated.
sm.setCheckSelectMode(false);
if (sm.multiple) {
sm.unselectAll();
}
const name = this.filesView.getItemLabel(newIndex);
sm.setIndexSelected(newIndex, true);
sm.anchorIndex = newIndex;
}
sm.endChange();
if (prevent) {
e.preventDefault();
}
}
};
/**
* Focus on the file list that contains the event target.
* @param {!Event} event the touch event.
*/
filelist.focusParentList = event => {
let element = event.target;
while (element && !(element instanceof cr.ui.List)) {
element = element.parentElement;
}
if (element) {
element.focus();
}
};
// eslint-disable-next-line semi,no-extra-semi
/* #export */ {filelist};