| // Copyright 2016 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/cr_elements/cr_shared_vars.css.js'; |
| import 'chrome://resources/polymer/v3_0/iron-list/iron-list.js'; |
| import './shared_style.css.js'; |
| import './strings.m.js'; |
| import './item.js'; |
| |
| import {getInstance as getAnnouncerInstance} from 'chrome://resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js'; |
| import {assert} from 'chrome://resources/js/assert_ts.js'; |
| import {isMac} from 'chrome://resources/js/platform.js'; |
| import {EventTracker} from 'chrome://resources/js/event_tracker.js'; |
| import {ListPropertyUpdateMixin} from 'chrome://resources/cr_elements/list_property_update_mixin.js'; |
| import {loadTimeData} from 'chrome://resources/js/load_time_data.js'; |
| import {PluralStringProxyImpl} from 'chrome://resources/js/plural_string_proxy.js'; |
| import {getDeepActiveElement} from 'chrome://resources/js/util_ts.js'; |
| import {IronListElement} from 'chrome://resources/polymer/v3_0/iron-list/iron-list.js'; |
| import {microTask, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js'; |
| |
| import {deselectItems, selectAll, selectItem, updateAnchor} from './actions.js'; |
| import {BookmarksCommandManagerElement} from './command_manager.js'; |
| import {MenuSource} from './constants.js'; |
| import {BookmarksItemElement} from './item.js'; |
| import {getTemplate} from './list.html.js'; |
| import {StoreClientMixin} from './store_client_mixin.js'; |
| import {OpenCommandMenuDetail} from './types.js'; |
| import {canReorderChildren, getDisplayedList} from './util.js'; |
| |
| const BookmarksListElementBase = |
| StoreClientMixin(ListPropertyUpdateMixin(PolymerElement)); |
| |
| export interface BookmarksListElement { |
| $: { |
| list: IronListElement, |
| message: HTMLDivElement, |
| }; |
| } |
| |
| export class BookmarksListElement extends BookmarksListElementBase { |
| static get is() { |
| return 'bookmarks-list'; |
| } |
| |
| static get template() { |
| return getTemplate(); |
| } |
| |
| static get properties() { |
| return { |
| /** |
| * A list of item ids wrapped in an Object. This is necessary because |
| * iron-list is unable to distinguish focusing index 6 from focusing id |
| * '6' so the item we supply to iron-list needs to be non-index-like. |
| */ |
| displayedList_: { |
| type: Array, |
| value() { |
| // Use an empty list during initialization so that the databinding to |
| // hide #list takes effect. |
| return []; |
| }, |
| }, |
| |
| displayedIds_: { |
| type: Array, |
| observer: 'onDisplayedIdsChanged_', |
| }, |
| |
| searchTerm_: { |
| type: String, |
| observer: 'onDisplayedListSourceChange_', |
| }, |
| |
| selectedFolder_: { |
| type: String, |
| observer: 'onDisplayedListSourceChange_', |
| }, |
| |
| selectedItems_: Object, |
| }; |
| } |
| |
| private displayedList_: Array<{id: string}>; |
| private displayedIds_: string[]; |
| private eventTracker_: EventTracker = new EventTracker(); |
| private searchTerm_: string; |
| private selectedFolder_: string; |
| private selectedItems_: Set<string>; |
| private boundOnHighlightItems_: (p1: CustomEvent) => void; |
| |
| override ready() { |
| super.ready(); |
| this.addEventListener('click', () => this.deselectItems_()); |
| this.addEventListener('contextmenu', |
| e => this.onContextMenu_(e as MouseEvent)); |
| this.addEventListener( |
| 'open-command-menu', |
| e => this.onOpenCommandMenu_(e as CustomEvent<OpenCommandMenuDetail>)); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| |
| const list = this.$.list; |
| list.scrollTarget = this; |
| |
| this.watch('displayedIds_', function(state) { |
| return getDisplayedList(state); |
| }); |
| this.watch('searchTerm_', state => state.search.term); |
| this.watch('selectedFolder_', state => state.selectedFolder); |
| this.watch('selectedItems_', state => state.selection.items); |
| this.updateFromStore(); |
| |
| this.$.list.addEventListener( |
| 'keydown', this.onItemKeydown_.bind(this), true); |
| |
| this.eventTracker_.add( |
| document, 'highlight-items', |
| (e: Event) => this.onHighlightItems_(e as CustomEvent<string[]>)); |
| this.eventTracker_.add( |
| document, 'import-began', () => this.onImportBegan_()); |
| this.eventTracker_.add( |
| document, 'import-ended', () => this.onImportEnded_()); |
| } |
| |
| override disconnectedCallback() { |
| super.disconnectedCallback(); |
| |
| this.eventTracker_.remove(document, 'highlight-items'); |
| } |
| |
| getDropTarget(): HTMLElement { |
| return this.$.message; |
| } |
| |
| /** |
| * Updates `displayedList_` using splices to be equivalent to `newValue`. This |
| * allows the iron-list to delete sublists of items which preserves scroll and |
| * focus on incremental update. |
| */ |
| private async onDisplayedIdsChanged_( |
| newValue: string[], _oldValue: string[]) { |
| const updatedList = newValue.map(id => ({id: id})); |
| let skipFocus = false; |
| let selectIndex = -1; |
| if (this.matches(':focus-within')) { |
| if (this.selectedItems_.size > 0) { |
| const selectedId = Array.from(this.selectedItems_)[0]; |
| skipFocus = newValue.some(id => id === selectedId); |
| selectIndex = |
| this.displayedList_.findIndex(({id}) => selectedId === id); |
| } |
| if (selectIndex === -1 && updatedList.length > 0) { |
| selectIndex = 0; |
| } else { |
| selectIndex = Math.min(selectIndex, updatedList.length - 1); |
| } |
| } |
| this.updateList( |
| 'displayedList_', item => (item as {id: string}).id, updatedList); |
| // Trigger a layout of the iron list. Otherwise some elements may render |
| // as blank entries. See https://crbug.com/848683 |
| this.$.list.dispatchEvent( |
| new CustomEvent('iron-resize', {bubbles: true, composed: true})); |
| const label = await PluralStringProxyImpl.getInstance().getPluralString( |
| 'listChanged', this.displayedList_.length); |
| getAnnouncerInstance().announce(label); |
| |
| if (!skipFocus && selectIndex > -1) { |
| setTimeout(() => { |
| this.$.list.focusItem(selectIndex); |
| // Focus menu button so 'Undo' is only one tab stop away on delete. |
| const item = getDeepActiveElement(); |
| if (item) { |
| (item as BookmarksItemElement).focusMenuButton(); |
| } |
| }); |
| } |
| } |
| |
| private onDisplayedListSourceChange_() { |
| this.scrollTop = 0; |
| } |
| |
| /** |
| * Scroll the list so that |itemId| is visible, if it is not already. |
| */ |
| private scrollToId_(itemId: string) { |
| const index = this.displayedIds_.indexOf(itemId); |
| const list = this.$.list; |
| if (index >= 0 && index < list.firstVisibleIndex || |
| index > list.lastVisibleIndex) { |
| list.scrollToIndex(index); |
| } |
| } |
| |
| private emptyListMessage_(): string { |
| let emptyListMessage = 'noSearchResults'; |
| if (!this.searchTerm_) { |
| emptyListMessage = |
| canReorderChildren(this.getState(), this.getState().selectedFolder) ? |
| 'emptyList' : |
| 'emptyUnmodifiableList'; |
| } |
| return loadTimeData.getString(emptyListMessage); |
| } |
| |
| private isEmptyList_(): boolean { |
| return this.displayedList_.length === 0; |
| } |
| |
| private deselectItems_() { |
| this.dispatch(deselectItems()); |
| } |
| |
| private getIndexForItemElement_(el: HTMLElement): number { |
| return (this.$.list.modelForElement(el) as unknown as {index: number}) |
| .index; |
| } |
| |
| private onOpenCommandMenu_(e: CustomEvent<{source: MenuSource}>) { |
| // If the item is not visible, scroll to it before rendering the menu. |
| if (e.detail.source === MenuSource.ITEM) { |
| this.scrollToId_((e.composedPath()[0] as BookmarksItemElement).itemId); |
| } |
| } |
| |
| /** |
| * Highlight a list of items by selecting them, scrolling them into view and |
| * focusing the first item. |
| */ |
| private onHighlightItems_(e: CustomEvent<string[]>) { |
| // Ensure that we only select items which are actually being displayed. |
| // This should only matter if an unrelated update to the bookmark model |
| // happens with the perfect timing to end up in a tracked batch update. |
| const toHighlight = |
| e.detail.filter((item) => this.displayedIds_.indexOf(item) !== -1); |
| |
| if (toHighlight.length <= 0) { |
| return; |
| } |
| |
| const leadId = toHighlight[0]!; |
| this.dispatch(selectAll(toHighlight, this.getState(), leadId)); |
| |
| // Allow iron-list time to render additions to the list. |
| microTask.run(() => { |
| this.scrollToId_(leadId); |
| const leadIndex = this.displayedIds_.indexOf(leadId); |
| assert(leadIndex !== -1); |
| this.$.list.focusItem(leadIndex); |
| }); |
| } |
| |
| private onImportBegan_() { |
| getAnnouncerInstance().announce(loadTimeData.getString('importBegan')); |
| } |
| |
| private onImportEnded_() { |
| getAnnouncerInstance().announce(loadTimeData.getString('importEnded')); |
| } |
| |
| private onItemKeydown_(e: KeyboardEvent) { |
| let handled = true; |
| const list = this.$.list; |
| let focusMoved = false; |
| let focusedIndex = this.getIndexForItemElement_(e.target as HTMLElement); |
| const oldFocusedIndex = focusedIndex; |
| const cursorModifier = isMac ? e.metaKey : e.ctrlKey; |
| if (e.key === 'ArrowUp') { |
| focusedIndex--; |
| focusMoved = true; |
| } else if (e.key === 'ArrowDown') { |
| focusedIndex++; |
| focusMoved = true; |
| e.preventDefault(); |
| } else if (e.key === 'Home') { |
| focusedIndex = 0; |
| focusMoved = true; |
| } else if (e.key === 'End') { |
| focusedIndex = list.items!.length - 1; |
| focusMoved = true; |
| } else if (e.key === ' ' && cursorModifier) { |
| this.dispatch( |
| selectItem(this.displayedIds_[focusedIndex]!, this.getState(), { |
| clear: false, |
| range: false, |
| toggle: true, |
| })); |
| } else { |
| handled = false; |
| } |
| |
| if (focusMoved) { |
| focusedIndex = |
| Math.min(list.items!.length - 1, Math.max(0, focusedIndex)); |
| list.focusItem(focusedIndex); |
| |
| if (cursorModifier && !e.shiftKey) { |
| this.dispatch(updateAnchor(this.displayedIds_[focusedIndex]!)); |
| } else { |
| // If shift-selecting with no anchor, use the old focus index. |
| if (e.shiftKey && this.getState().selection.anchor === null) { |
| this.dispatch(updateAnchor(this.displayedIds_[oldFocusedIndex]!)); |
| } |
| |
| // If the focus moved from something other than a Ctrl + move event, |
| // update the selection. |
| const config = { |
| clear: !cursorModifier, |
| range: e.shiftKey, |
| toggle: false, |
| }; |
| |
| this.dispatch(selectItem( |
| this.displayedIds_[focusedIndex]!, this.getState(), config)); |
| } |
| } |
| |
| // Prevent the iron-list from changing focus on enter. |
| if (e.key === 'Enter') { |
| if ((e.composedPath()[0] as HTMLElement).tagName === 'CR-ICON-BUTTON') { |
| return; |
| } |
| if (e.composedPath()[0] instanceof HTMLButtonElement) { |
| handled = true; |
| } |
| } |
| |
| if (!handled) { |
| handled = BookmarksCommandManagerElement.getInstance().handleKeyEvent( |
| e, this.getState().selection.items); |
| } |
| |
| if (handled) { |
| e.stopPropagation(); |
| } |
| } |
| |
| private onContextMenu_(e: MouseEvent) { |
| e.preventDefault(); |
| this.deselectItems_(); |
| |
| this.dispatchEvent(new CustomEvent('open-command-menu', { |
| bubbles: true, |
| composed: true, |
| detail: { |
| x: e.clientX, |
| y: e.clientY, |
| source: MenuSource.LIST, |
| }, |
| })); |
| } |
| |
| private getAriaRowindex_(index: number): number { |
| return index + 1; |
| } |
| |
| private getAriaSelected_(id: string): boolean { |
| return this.selectedItems_.has(id); |
| } |
| |
| setDisplayedIdsForTesting(ids: string[]) { |
| this.displayedIds_ = ids; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'bookmarks-list': BookmarksListElement; |
| } |
| } |
| |
| customElements.define(BookmarksListElement.is, BookmarksListElement); |