export type ContentMenuItemTitle = string | ((page: unknown) => string) export interface ContentMenuBaseItem { type?: "header" | "separator" | "action" title?: ContentMenuItemTitle tooltip?: string action?: (page: unknown) => void disabled?: (page: unknown) => boolean selected?: boolean order?: number icon?: string } export interface ContentMenuHeaderItem extends ContentMenuBaseItem { type: "header" title: ContentMenuItemTitle } export interface ContentMenuSeparatorItem extends ContentMenuBaseItem { type: "separator" } export interface ContentMenuActionItem extends ContentMenuBaseItem { type?: "action" title: ContentMenuItemTitle action: (page: unknown) => void } export type ContentMenuItem = ContentMenuHeaderItem | ContentMenuSeparatorItem | ContentMenuActionItem export interface ContentMenuInit { content: ContentMenuItem[] } export interface ContentMenuPosition { X: number Y: number } export interface ContentMenuOptions { id?: string | false page?: unknown | false classes?: string | false menu?: ContentMenuInit height?: number | false width?: number | false onClose?: (() => void) | false scroll?: boolean | false dialogEl?: HTMLElement | false backdropEl?: HTMLElement | false menuPos?: ContentMenuPosition | false } const menuTemplate = ({ id, classes, height, width, zIndex, menu, scroll, page }: { id: string | false classes: string | false height: string width: string zIndex: number menu: ContentMenuInit scroll: boolean | false page: unknown | false }) => `
    ${menu.content .map((menuItem, index) => { switch (menuItem.type) { case "header": return `
  • ${ typeof menuItem.title === "function" ? menuItem.title(page) : menuItem.title }
  • ` case "separator": return '

  • ' default: return `` } }) .join("")}
` export class ContentMenu { id: string | false page: unknown | false classes: string | false menu: ContentMenuInit height: string width: string onClose: (() => void) | false scroll: boolean | false dialogEl!: HTMLElement backdropEl!: HTMLElement menuPos: ContentMenuPosition | false focusedIndex: number previouslyFocusedElement: Element | null constructor({ id = false, page = false, classes = false, menu = {content: []}, height = false, width = false, onClose = false, scroll = false, dialogEl = false, backdropEl = false, menuPos = false }: ContentMenuOptions = {}) { this.id = id this.page = page this.classes = classes this.menu = menu this.height = height ? `${height}px` : "auto" this.width = width ? `${width}px` : "auto" this.onClose = onClose this.scroll = scroll this.dialogEl = (dialogEl || null) as HTMLElement this.backdropEl = (backdropEl || null) as HTMLElement this.menuPos = menuPos this.focusedIndex = 0 this.previouslyFocusedElement = null } open(): void { if (this.dialogEl) { return } this.previouslyFocusedElement = document.activeElement document.body.insertAdjacentHTML( "beforeend", menuTemplate({ id: this.id, classes: this.classes, height: this.height, width: this.width, zIndex: this.getHighestDialogZIndex() + 2, menu: this.menu, scroll: this.scroll, page: this.page }) ) this.backdropEl = document.body.lastElementChild as HTMLElement this.dialogEl = this.backdropEl.previousElementSibling as HTMLElement if (this.menuPos && this.menuPos.X && this.menuPos.Y) { this.positionDialog() } else { this.centerDialog() } this.checkAndAddColumns() this.bind() this.focusFirstMenuItem() } renderColumnsHtml(columns: number): string { const itemsPerColumn = Math.ceil(this.menu.content.length / columns) let html = '
' for (let col = 0; col < columns; col++) { const start = col * itemsPerColumn const end = Math.min( start + itemsPerColumn, this.menu.content.length ) if (start >= this.menu.content.length) { break } html += '" } html += "
" return html } checkAndAddColumns(): void { const dialogRect = this.dialogEl.getBoundingClientRect() const viewportHeight = window.innerHeight const viewportWidth = window.innerWidth const maxHeight = viewportHeight * 0.9 const maxWidth = viewportWidth * 0.95 if (dialogRect.height >= maxHeight) { const contentEl = this.dialogEl.querySelector( ".fw-content-menu-content" ) as HTMLElement const contentDiv = contentEl.querySelector( ":scope > div" ) as HTMLElement let columns = 2 while (columns <= 6) { contentDiv.innerHTML = this.renderColumnsHtml(columns) const columnsDiv = contentDiv.querySelector( ".fw-content-menu-columns" ) if (columnsDiv) { const naturalWidth = columnsDiv.scrollWidth + 20 contentEl.style.width = `${naturalWidth}px` } const newRect = this.dialogEl.getBoundingClientRect() if (newRect.height < maxHeight && newRect.width < maxWidth) { if (this.menuPos && this.menuPos.X && this.menuPos.Y) { this.positionDialog() } else { this.centerDialog() } return } columns++ } // Fallback: restore single column with scrolling contentDiv.innerHTML = `` contentEl.style.width = this.width this.dialogEl .querySelector(".fw-content-menu-content")! .classList.add("fw-scrollable") if (this.menuPos && this.menuPos.X && this.menuPos.Y) { this.positionDialog() } else { this.centerDialog() } } } renderSingleColumnHtml(): string { return this.menu.content .map((menuItem, index) => { switch (menuItem.type) { case "header": return `
  • ${ typeof menuItem.title === "function" ? menuItem.title(this.page) : menuItem.title }
  • ` case "separator": return '

  • ' default: return `` } }) .join("") } centerDialog(): void { const totalWidth = window.innerWidth, totalHeight = window.innerHeight, dialogRect = this.dialogEl.getBoundingClientRect(), dialogWidth = dialogRect.width + 10, dialogHeight = dialogRect.height + 10, scrollTopOffset = window.pageYOffset, scrollLeftOffset = window.pageXOffset this.dialogEl.style.top = `${(totalHeight - dialogHeight) / 2 + scrollTopOffset}px` this.dialogEl.style.left = `${(totalWidth - dialogWidth) / 2 + scrollLeftOffset}px` } positionDialog(): void { const dialogHeight = this.dialogEl.getBoundingClientRect().height + 10, dialogWidth = this.dialogEl.getBoundingClientRect().width + 10, scrollTopOffset = window.pageYOffset, clientHeight = window.document.documentElement.clientHeight, clientWidth = window.document.documentElement.clientWidth // We try to ensure that the menu is seen in the browser at the preferred location. // Adjustments are made in case it doesn't fit. let top = this.menuPos ? this.menuPos.Y : 0, left = this.menuPos ? this.menuPos.X : 0 if (top + dialogHeight > scrollTopOffset + clientHeight) { top -= top + dialogHeight - (scrollTopOffset + clientHeight) } if (top < scrollTopOffset) { top = scrollTopOffset + 10 } if (left + dialogWidth > clientWidth) { left -= left + dialogWidth - clientWidth } this.dialogEl.style.top = `${top}px` this.dialogEl.style.left = `${left}px` } bind(): void { this.backdropEl.addEventListener("click", () => this.close()) this.dialogEl.addEventListener("click", event => this.onclick(event)) this.dialogEl.addEventListener("keydown", event => this.onKeyDown(event) ) this.dialogEl.focus() } getHighestDialogZIndex(): number { // Dialogs carry their z-index in the stylesheet (1000), not inline, // so the inline style alone reads empty here and the menu would end // up under the dialog it was opened from. Use the computed style, // mirroring Dialog.nextDialogZIndex(). let zIndex = 100 document .querySelectorAll("div.fw-content-menu, div.fw-dialog") .forEach(dialogEl => { const computedZIndex = parseInt( window.getComputedStyle(dialogEl as HTMLElement).zIndex ) zIndex = Math.max( zIndex, Number.isNaN(computedZIndex) ? 100 : computedZIndex ) }) return zIndex } close(): void { if (!this.dialogEl || !this.dialogEl.parentElement) { return } this.dialogEl.parentElement.removeChild(this.dialogEl) this.backdropEl.parentElement!.removeChild(this.backdropEl) // Restore focus to the previously focused element if ( this.previouslyFocusedElement && (this.previouslyFocusedElement as HTMLElement).focus ) { ;(this.previouslyFocusedElement as HTMLElement).focus() } if (this.onClose) { this.onClose() } } onclick(event: MouseEvent): void { event.preventDefault() event.stopImmediatePropagation() const target = (event.target as Element).closest( "li.fw-content-menu-item" ) as HTMLElement if (target) { const menuNumber = target.dataset.index if (menuNumber === undefined) { return } const menuItem = this.menu.content[ parseInt(menuNumber) ] as ContentMenuActionItem if (menuItem.disabled?.(this.page)) { return } menuItem.action(this.page) this.close() } } onKeyDown(event: KeyboardEvent): void { const {key} = event const menuItems = this.dialogEl.querySelectorAll( "li.fw-content-menu-item:not(.fw-disabled)" ) const columnsDiv = this.dialogEl.querySelector( ".fw-content-menu-columns" ) const totalColumns = columnsDiv ? columnsDiv.querySelectorAll(".fw-content-menu-list").length : 1 const itemsPerColumn = totalColumns > 1 ? Math.ceil(menuItems.length / totalColumns) : menuItems.length switch (key) { case "Escape": this.close() break case "ArrowUp": event.preventDefault() this.focusedIndex = (this.focusedIndex - 1 + menuItems.length) % menuItems.length this.focusMenuItem(this.focusedIndex) break case "ArrowDown": event.preventDefault() this.focusedIndex = (this.focusedIndex + 1) % menuItems.length this.focusMenuItem(this.focusedIndex) break case "ArrowLeft": event.preventDefault() if (totalColumns <= 1) { break } { const currentCol = Math.floor( this.focusedIndex / itemsPerColumn ) const currentRow = this.focusedIndex % itemsPerColumn if (currentCol > 0) { let newIndex = (currentCol - 1) * itemsPerColumn + currentRow const prevColEnd = Math.min( currentCol * itemsPerColumn, menuItems.length ) if (newIndex >= prevColEnd) { newIndex = prevColEnd - 1 } this.focusedIndex = newIndex this.focusMenuItem(this.focusedIndex) } } break case "ArrowRight": event.preventDefault() if (totalColumns <= 1) { break } { const currentCol = Math.floor( this.focusedIndex / itemsPerColumn ) const currentRow = this.focusedIndex % itemsPerColumn if (currentCol < totalColumns - 1) { let newIndex = (currentCol + 1) * itemsPerColumn + currentRow const nextColEnd = Math.min( (currentCol + 2) * itemsPerColumn, menuItems.length ) if (newIndex >= nextColEnd) { newIndex = nextColEnd - 1 } this.focusedIndex = newIndex this.focusMenuItem(this.focusedIndex) } } break case "Tab": event.preventDefault() if (event.shiftKey) { this.focusedIndex = (this.focusedIndex - 1 + menuItems.length) % menuItems.length } else { this.focusedIndex = (this.focusedIndex + 1) % menuItems.length } this.focusMenuItem(this.focusedIndex) break case "Enter": case " ": { event.preventDefault() const menuItem = this.menu.content[ this.focusedIndex ] as ContentMenuActionItem if (!menuItem.disabled?.(this.page)) { menuItem.action(this.page) this.close() } break } } } focusFirstMenuItem(): void { const menuItems = this.dialogEl.querySelectorAll( "li.fw-content-menu-item:not(.fw-disabled)" ) if (menuItems.length > 0) { this.focusedIndex = 0 this.focusMenuItem(this.focusedIndex) } } focusMenuItem(index: number): void { const menuItems = this.dialogEl.querySelectorAll( "li.fw-content-menu-item:not(.fw-disabled)" ) if (menuItems[index]) { ;(menuItems[index] as HTMLElement).focus() } } }