// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Forked from
// ui/webui/resources/cr_elements/cr_menu_selector/cr_menu_selector.ts
import {assert} from '//resources/js/assert.js';
import {FocusOutlineManager} from '//resources/js/focus_outline_manager.js';
import {IronSelectableBehavior} from '//resources/polymer/v3_0/iron-selector/iron-selectable.js';
import {mixinBehaviors, PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
const CrMenuSelectorBase =
mixinBehaviors([IronSelectableBehavior], PolymerElement) as
{new (): PolymerElement & IronSelectableBehavior};
export class CrMenuSelector extends CrMenuSelectorBase {
static get is() {
return 'cr-menu-selector';
}
private focusOutlineManager_: FocusOutlineManager;
override connectedCallback() {
super.connectedCallback();
this.focusOutlineManager_ = FocusOutlineManager.forDocument(document);
}
override ready() {
super.ready();
this.setAttribute('role', 'menu');
this.addEventListener('focusin', this.onFocusin_.bind(this));
this.addEventListener('keydown', this.onKeydown_.bind(this));
this.addEventListener(
'iron-deselect',
e => this.onIronDeselected_(e as CustomEvent<{item: HTMLElement}>));
this.addEventListener(
'iron-select',
e => this.onIronSelected_(e as CustomEvent<{item: HTMLElement}>));
}
private getAllFocusableItems_(): HTMLElement[] {
// Note that this is different from IronSelectableBehavior's items property
// as some items are focusable and actionable but not selectable (eg. an
// external link).
return Array.from(
this.querySelectorAll('[role=menuitem]:not([disabled]):not([hidden])'));
}
private onFocusin_(e: FocusEvent) {
// If the focus was moved by keyboard and is coming in from a relatedTarget
// that is not within this menu, move the focus to the first menu item. This
// ensures that the first menu item is always the first focused item when
// focusing into the menu. A null relatedTarget means the focus was moved
// from outside the WebContents.
const focusMovedWithKeyboard = this.focusOutlineManager_.visible;
const focusMovedFromOutside = e.relatedTarget === null ||
!this.contains(e.relatedTarget as HTMLElement);
if (focusMovedWithKeyboard && focusMovedFromOutside) {
this.getAllFocusableItems_()[0]!.focus();
}
}
private onIronDeselected_(e: CustomEvent<{item: HTMLElement}>) {
e.detail.item.removeAttribute('aria-current');
}
private onIronSelected_(e: CustomEvent<{item: HTMLElement}>) {
e.detail.item.setAttribute('aria-current', 'page');
}
private onKeydown_(event: KeyboardEvent) {
const items = this.getAllFocusableItems_();
assert(items.length >= 1);
const currentFocusedIndex =
items.indexOf(this.querySelector<HTMLElement>(':focus')!);
let newFocusedIndex = currentFocusedIndex;
switch (event.key) {
case 'Tab':
if (event.shiftKey) {
// If pressing Shift+Tab, immediately focus the first element so that
// when the event is finished processing, the browser automatically
// focuses the previous focusable element outside of the menu.
items[0]!.focus();
} else {
// If pressing Tab, immediately focus the last element so that when
// the event is finished processing, the browser automatically focuses
// the next focusable element outside of the menu.
items[items.length - 1]!.focus({preventScroll: true});
}
return;
case 'ArrowDown':
newFocusedIndex = (currentFocusedIndex + 1) % items.length;
break;
case 'ArrowUp':
newFocusedIndex =
(currentFocusedIndex + items.length - 1) % items.length;
break;
case 'Home':
newFocusedIndex = 0;
break;
case 'End':
newFocusedIndex = items.length - 1;
break;
}
if (newFocusedIndex === currentFocusedIndex) {
return;
}
event.preventDefault();
items[newFocusedIndex]!.focus();
}
}
declare global {
interface HTMLElementTagNameMap {
'cr-menu-selector': CrMenuSelector;
}
}
customElements.define(CrMenuSelector.is, CrMenuSelector);