import {DiffDOM} from "diff-dom" import {keyName} from "w3c-keyname" import {escapeText, whenReady} from "./basic.js" import {gettext} from "./settings.js" export interface OverviewMenuDropdownOption { title: string action?: (overview: unknown) => void [key: string]: unknown } export interface OverviewMenuBaseItem { id?: string title?: string keys?: string action?: (overview: unknown) => void [key: string]: unknown } export interface OverviewMenuDropdownItem extends OverviewMenuBaseItem { type: "dropdown" open?: boolean selectedIndex?: number content: OverviewMenuDropdownOption[] } export interface OverviewMenuSelectActionDropdownItem extends OverviewMenuBaseItem { type: "select-action-dropdown" open?: boolean checked?: boolean content: OverviewMenuDropdownOption[] checkAction?: (overview: unknown) => void uncheckAction?: (overview: unknown) => void } export interface OverviewMenuTextItem extends OverviewMenuBaseItem { type: "text" action?: (overview: unknown) => void } export interface OverviewMenuButtonItem extends OverviewMenuBaseItem { type: "button" action?: (overview: unknown) => void icon?: string } export interface OverviewMenuSearchItem extends OverviewMenuBaseItem { type: "search" placeholder?: string icon?: string input?: (overview: unknown, value: string) => void } export type OverviewMenuItem = | OverviewMenuDropdownItem | OverviewMenuSelectActionDropdownItem | OverviewMenuTextItem | OverviewMenuButtonItem | OverviewMenuSearchItem export interface OverviewMenuModel { content: OverviewMenuItem[] } export class OverviewMenuView { overview: unknown model: OverviewMenuModel dd: DiffDOM openedMenu: number | false listeners: Record void> keyboardShortcuts: Map menuEl: HTMLElement | null constructor(overview: unknown, model: () => OverviewMenuModel) { this.overview = overview this.model = model() this.dd = new DiffDOM({ valueDiffing: false }) this.openedMenu = false this.listeners = {} this.keyboardShortcuts = new Map() this.menuEl = null } init(): void { whenReady().then(() => { this.addMissingIds(this.model) this.bindEvents() this.setupKeyboardShortcuts() }) } addMissingIds(menu: { content: Array<{id?: string; type?: string; content?: unknown[]}> }): void { // Add missing ids to menu items that don't have an ID. menu.content.forEach(item => { if (!item.id) { item.id = Math.random().toString(36).substring(2) } if (item.type === "dropdown") { this.addMissingIds( item as { content: Array<{ id?: string type?: string content?: unknown[] }> } ) } }) } bindEvents(): void { this.menuEl = document.getElementById("fw-overview-menu") this.listeners.onclick = event => this.onclick(event as MouseEvent) document.body.addEventListener("click", this.listeners.onclick) this.listeners.oninput = event => this.oninput(event as InputEvent) document.body.addEventListener("input", this.listeners.oninput) this.listeners.onKeydown = event => this.onKeydown(event as KeyboardEvent) document.body.addEventListener("keydown", this.listeners.onKeydown) this.listeners.onFocus = event => this.onFocus(event as FocusEvent) document.body.addEventListener("focus", this.listeners.onFocus, true) this.update() } setupKeyboardShortcuts(): void { // Map all keyboard shortcuts from the menu model this.model.content.forEach(menuItem => { if (menuItem.keys) { this.keyboardShortcuts.set( menuItem.keys.toLowerCase(), menuItem ) } }) } onKeydown(event: KeyboardEvent): void { let name = keyName(event) if (event.altKey) { name = "alt-" + name.toLowerCase() const menuItem = this.keyboardShortcuts.get(name) if (menuItem) { event.preventDefault() event.stopPropagation() event.stopImmediatePropagation() if (menuItem.type === "search") { const inputEl = document.getElementById( `${menuItem.id}-input` ) if (inputEl) { inputEl.focus() } } else if (menuItem.type === "dropdown") { // Toggle dropdown menu // If the menu is already open, close it if ( this.openedMenu === this.model.content.indexOf(menuItem) ) { ;(menuItem as OverviewMenuDropdownItem).open = false this.openedMenu = false this.update() } else { if (this.openedMenu !== false) { ;( this.model.content[ this.openedMenu ] as OverviewMenuDropdownItem ).open = false } ;(menuItem as OverviewMenuDropdownItem).open = true this.openedMenu = this.model.content.indexOf(menuItem) this.update() const firstDropdownItem = this.menuEl!.querySelector( `.fw-pulldown-item.selected` ) if (firstDropdownItem) { ;(firstDropdownItem as HTMLElement).focus() } } } else if (menuItem.action) { menuItem.action(this.overview) } return } } // Handle horizontal navigation between menu items if (name === "ArrowLeft" || name === "ArrowRight") { const menuItems = Array.from( this.menuEl!.querySelectorAll("#fw-overview-menu > li") ) const focusedElement = document.activeElement const currentMenuItem = focusedElement?.closest("li") if (currentMenuItem) { event.preventDefault() const currentIndex = menuItems.indexOf(currentMenuItem) let newIndex: number if (name === "ArrowLeft") { newIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1 } else { newIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0 } const nextMenuItem = menuItems[newIndex].querySelector( ".fw-dropdown-menu, .fw-text-menu, button, input" ) if (nextMenuItem) { ;(nextMenuItem as HTMLElement).focus() } } return } // Handle Enter and Space to open dropdown menus if (name === "Enter" || name === " ") { const focusedElement = document.activeElement if (focusedElement?.matches(".fw-dropdown-menu")) { event.preventDefault() const menuItem = this.findMenuItemFromElement(focusedElement) if (menuItem && menuItem.type === "dropdown") { if (this.openedMenu !== false) { ;( this.model.content[ this.openedMenu ] as OverviewMenuDropdownItem ).open = false } ;(menuItem as OverviewMenuDropdownItem).open = true this.openedMenu = this.model.content.indexOf(menuItem) ;(menuItem as OverviewMenuDropdownItem).selectedIndex = 0 this.update() const firstDropdownItem = this.menuEl!.querySelector( `.fw-pulldown-item.selected` ) if (firstDropdownItem) { ;(firstDropdownItem as HTMLElement).focus() } } return } } if (this.openedMenu !== false) { const menuItem = this.model.content[this.openedMenu] if (menuItem.type === "dropdown") { const dropdownItem = menuItem as OverviewMenuDropdownItem if (name === "ArrowDown" || name === "ArrowUp") { event.preventDefault() event.stopPropagation() // Find currently selected item let selectedIndex = -1 if (dropdownItem.selectedIndex !== undefined) { selectedIndex = dropdownItem.selectedIndex } // Calculate new index if (name === "ArrowDown") { selectedIndex = selectedIndex < dropdownItem.content.length - 1 ? selectedIndex + 1 : 0 } else { selectedIndex -= 1 if (selectedIndex < 0) { // Close menu dropdownItem.open = false this.openedMenu = false delete dropdownItem.selectedIndex this.update() const dropdownButton = this.menuEl!.querySelector( `#${menuItem.id}-button` ) if (dropdownButton) { ;(dropdownButton as HTMLElement).focus() } } } dropdownItem.selectedIndex = selectedIndex this.update() const selectedEl = this.menuEl!.querySelector( `.fw-pulldown-item.selected` ) if (selectedEl) { ;(selectedEl as HTMLElement).focus() } } else if (name === "Enter" || name === " ") { event.preventDefault() event.stopPropagation() if ( dropdownItem.selectedIndex !== undefined && dropdownItem.content[dropdownItem.selectedIndex] ) { const selectedItem = dropdownItem.content[dropdownItem.selectedIndex] if (selectedItem.action) { selectedItem.action(this.overview) dropdownItem.open = false this.openedMenu = false delete dropdownItem.selectedIndex this.update() } } } else if (name === "Escape") { event.preventDefault() event.stopPropagation() dropdownItem.open = false this.openedMenu = false delete dropdownItem.selectedIndex this.update() const dropdownButton = document.getElementById( `${menuItem.id}-button` ) if (dropdownButton) { dropdownButton.focus() } } } } } onFocus(event: FocusEvent): void { // Ignore if the focus event is triggered by JavaScript if (event.isTrusted === false) { return } const target = event.target as Element if (this.openedMenu !== false) { if (target.matches("#fw-overview-menu li .fw-pulldown-item")) { const menuItem = this.model.content[this.openedMenu] if (menuItem) { const itemNumber = Array.from( target.parentElement!.parentElement!.children ).indexOf(target.parentElement as Element) ;(menuItem as OverviewMenuDropdownItem).selectedIndex = itemNumber this.update() } } else { // Close dropdown menu if focus is outside of the dropdown const menuItem = this.model.content[this.openedMenu] if (menuItem) { ;(menuItem as OverviewMenuDropdownItem).open = false delete (menuItem as OverviewMenuDropdownItem).selectedIndex this.openedMenu = false this.update() } } } } findMenuItemFromElement(element: Element): OverviewMenuItem | null { const menuItem = element.closest("li") if (!menuItem) { return null } let menuNumber = 0 let seekItem: Element | null = menuItem while (seekItem.previousElementSibling) { menuNumber++ seekItem = seekItem.previousElementSibling } return this.model.content[menuNumber] } focusMenuItem(menuItem: OverviewMenuItem): void { const menuEl = this.menuEl!.querySelector(`#${menuItem.id}`) if (menuEl) { ;(menuEl as HTMLElement).focus() } } oninput(event: InputEvent): void { const target = event.target as Element if (target.matches("#fw-overview-menu > li > .fw-button > input")) { // A text was entered in a top entry. we find which one. let menuNumber = 0 let seekItem: Element | null = target.closest("li") while (seekItem?.previousElementSibling) { menuNumber++ seekItem = seekItem.previousElementSibling } const menuItem = this.model.content[menuNumber] if (menuItem.type === "search" && menuItem.input) { menuItem.input( this.overview, (target as HTMLInputElement).value ) ;(target as HTMLElement).focus() } } } onclick(event: MouseEvent): void | false | true { const target = event.target as Element if ( target.matches("#fw-overview-menu li li, #fw-overview-menu li li *") ) { event.preventDefault() let itemNumber = 0 let seekItem: Element | null = target.closest("li") while (seekItem?.previousElementSibling) { itemNumber++ seekItem = seekItem.previousElementSibling } let menuNumber = 0 seekItem = seekItem!.parentElement!.parentElement!.parentElement while (seekItem?.previousElementSibling) { menuNumber++ seekItem = seekItem.previousElementSibling } const menuItem = this.model.content[menuNumber] if (menuItem.type === "dropdown") { menuItem.content[itemNumber].action!(this.overview) menuItem.open = false if (menuItem.type === "dropdown") { menuItem.title = menuItem.content[itemNumber].title this.openedMenu = false this.update() } } return false } else if ( target.matches( "#fw-overview-menu li .fw-select-action input[type=checkbox]" ) ) { event.preventDefault() event.stopImmediatePropagation() event.stopPropagation() // A toolbar dropdown menu item was clicked. We just need to // find out which one let menuNumber = 0 let seekItem: Element | null = target.closest("li") while (seekItem?.previousElementSibling) { menuNumber++ seekItem = seekItem.previousElementSibling } const menuItem = this.model.content[ menuNumber ] as OverviewMenuSelectActionDropdownItem if (menuItem.checked === true) { menuItem.checked = false if (menuItem.uncheckAction) { menuItem.uncheckAction(this.overview) } } else { menuItem.checked = true if (menuItem.checkAction) { menuItem.checkAction(this.overview) } } return true } else if ( target.matches("#fw-overview-menu li, #fw-overview-menu li *") ) { // A toolbar dropdown menu item was clicked. We just need to // find out which one let menuNumber = 0 let seekItem: Element | null = target.closest("li") while (seekItem?.previousElementSibling) { menuNumber++ seekItem = seekItem.previousElementSibling } const menuItem = this.model.content[menuNumber] // if it is a dropdown menu, open it. Otherwise execute an // associated action. if ( ["dropdown", "select-action-dropdown"].includes( menuItem.type || "" ) ) { event.preventDefault() if (this.openedMenu === menuNumber) { ;( this.model.content[ this.openedMenu ] as OverviewMenuDropdownItem ).open = false this.openedMenu = false } else { if (this.openedMenu !== false) { ;( this.model.content[ this.openedMenu ] as OverviewMenuDropdownItem ).open = false } ;(menuItem as OverviewMenuDropdownItem).open = true this.openedMenu = menuNumber } this.update() } else if (menuItem.action) { event.preventDefault() menuItem.action(this.overview) this.announceForScreenReader(gettext("Action completed")) if (this.openedMenu !== false) { ;( this.model.content[ this.openedMenu ] as OverviewMenuDropdownItem ).open = false this.openedMenu = false } this.update() } return false } else if (this.openedMenu !== false) { ;( this.model.content[this.openedMenu] as OverviewMenuDropdownItem ).open = false this.openedMenu = false this.update() } } update(): void { if (!this.menuEl) { // page has not yet been loaded. abort return } const diff = this.dd.diff(this.menuEl, this.getMenuHTML()) this.dd.apply(this.menuEl, diff) } getMenuHTML(): string { return `` } // Underline access keys getAccessKeyHTML(title: string, accessKey: string | undefined): string { if (!accessKey) { return escapeText(title) } const index = title.toLowerCase().indexOf(accessKey.toLowerCase()) if (index === -1) { return escapeText(title) } return `${escapeText(title.substring(0, index))}${escapeText( title.charAt(index) )}${escapeText(title.substring(index + 1))}` } getMenuItemHTML(menuItem: OverviewMenuItem): string { let returnValue: string switch (menuItem.type) { case "dropdown": returnValue = this.getDropdownHTML(menuItem) break case "select-action-dropdown": returnValue = this.getSelectionActionDropdownHTML(menuItem) break case "text": returnValue = this.getTextHTML(menuItem) break case "button": returnValue = this.getButtonHTML(menuItem) break case "search": returnValue = this.getSearchHTML(menuItem) break default: returnValue = "" break } return returnValue } getSelectionActionDropdownHTML( menuItem: OverviewMenuSelectActionDropdownItem ): string { return `
${this.getDropdownListHTML(menuItem)} ` } getDropdownHTML(menuItem: OverviewMenuDropdownItem): string { const accessKey = menuItem.keys?.split("-")[1] return ` ${this.getDropdownListHTML(menuItem)} ` } getDropdownListHTML( menuItem: OverviewMenuDropdownItem | OverviewMenuSelectActionDropdownItem ): string { if (menuItem.open) { return `` } else { return "" } } getDropdownOptionHTML( menuOption: OverviewMenuDropdownOption, index: number ): string { const menuItem = this.model.content[ this.openedMenu as number ] as OverviewMenuDropdownItem const isSelected = menuItem.selectedIndex === index return `
  • ${escapeText(menuOption.title)}
  • ` } getButtonHTML(menuItem: OverviewMenuButtonItem): string { return ` ` } announceForScreenReader(message: string): void { const announcement = document.createElement("div") announcement.setAttribute("aria-live", "polite") announcement.classList.add("sr-only") // CSS to visually hide but keep available to screen readers announcement.textContent = message document.body.appendChild(announcement) setTimeout(() => announcement.remove(), 1000) } getTextHTML(menuItem: OverviewMenuTextItem): string { const accessKey = menuItem.keys?.split("-")[1] return ` ` } getSearchHTML(menuItem: OverviewMenuSearchItem): string { const accessKey = menuItem.keys?.split("-")[1] return `
    ${menuItem.icon ? `` : ""}
    ` } destroy(): void { // Remove all event listeners document.body.removeEventListener("click", this.listeners.onclick) document.body.removeEventListener("input", this.listeners.oninput) document.body.removeEventListener("keydown", this.listeners.onKeydown) document.body.removeEventListener("focus", this.listeners.onFocus) // Clear references this.listeners = {} this.keyboardShortcuts.clear() this.menuEl = null this.openedMenu = false } }