diff --git a/web/menu.css b/web/menu.css new file mode 100644 index 000000000..8d5cee607 --- /dev/null +++ b/web/menu.css @@ -0,0 +1,177 @@ +/* Copyright 2025 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.popupMenu { + --menuitem-checkmark-icon: url(images/checkmark.svg); + --menu-mark-icon-size: 0; + --menu-icon-size: 16px; + --menuitem-gap: 5px; + --menuitem-border-color: transparent; + --menuitem-active-bg: color-mix( + in srgb, + var(--menu-text-color), + transparent 79% + ); + --menuitem-text-active-fg: var(--menu-text-color); + --menuitem-focus-bg: color-mix( + in srgb, + var(--menu-text-color), + transparent 93% + ); + --menuitem-focus-outline-color: light-dark(#0062fa, #00cadb); + --menuitem-focus-border-color: light-dark(white, black); + + @media screen and (forced-colors: active) { + --menu-bg: Canvas; + --menu-background-blend-mode: normal; + --menu-box-shadow: none; + --menu-backdrop-filter: none; + --menu-text-color: ButtonText; + --menu-border-color: CanvasText; + --menuitem-border-color: none; + --menuitem-hover-bg: SelectedItemText; + --menuitem-text-hover-fg: SelectedItem; + --menuitem-active-bg: SelectedItemText; + --menuitem-text-active-fg: SelectedItem; + --menuitem-focus-outline-color: CanvasText; + --menuitem-focus-border-color: none; + } + + display: flex; + flex-direction: column; + width: max-content; + height: auto; + position: relative; + left: 0; + top: 1px; + margin: 0; + padding: 5px; + + background: var(--menu-bg); + background-blend-mode: var(--menu-background-blend-mode); + box-shadow: var(--menu-box-shadow); + border-radius: 6px; + border: 1px solid var(--menu-border-color); + backdrop-filter: var(--menu-backdrop-filter); + + &.withMark { + --menu-mark-icon-size: 16px; + } + + > li { + display: flex; + align-items: center; + list-style: none; + width: 100%; + height: 24px; + padding-inline: calc(var(--menu-mark-icon-size) + var(--menuitem-gap)) + var(--menuitem-gap); + gap: var(--menuitem-gap); + box-sizing: border-box; + border-radius: var(--menuitem-border-radius); + border: 1px solid var(--menuitem-border-color); + background: transparent; + + &:has(button.selected)::before { + content: ""; + display: inline-block; + width: 11px; + height: 11px; + mask-repeat: no-repeat; + mask-position: center; + mask-image: var(--menuitem-checkmark-icon); + background-color: var(--menu-text-color); + position: absolute; + margin-left: -16px; + } + + &:has(button:disabled) { + opacity: 0.62; + pointer-events: none; + } + + &:hover { + background: var(--menuitem-hover-bg); + background-blend-mode: var(--menuitem-hover-background-blend-mode); + > button { + &:not(.noIcon)::before { + background-color: var(--menuitem-text-hover-fg); + } + > span { + color: var(--menuitem-text-hover-fg); + } + } + &:has(button.selected)::before { + background-color: var(--menuitem-text-hover-fg); + } + } + + &:active { + background-color: var(--menuitem-active-bg); + > button > span { + color: var(--menuitem-text-active-fg); + } + } + + &:has(> button:focus-visible) { + border-color: var(--menuitem-focus-border-color); + background-color: var(--menuitem-focus-bg); + outline: 2px solid var(--menuitem-focus-outline-color); + outline-offset: 2px; + } + + > button { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + height: auto; + padding: var(--menuitem-gap); + gap: var(--menuitem-gap); + background: transparent; + border: none; + + &:not(.noIcon)::before { + display: inline-block; + width: var(--menu-icon-size); + height: var(--menu-icon-size); + content: ""; + mask-size: cover; + mask-position: center; + background-color: var(--menu-text-color); + } + + &:focus-visible { + outline: none; + } + + > span { + display: inline-block; + width: max-content; + height: auto; + text-align: left; + color: var(--menu-text-color); + user-select: none; + padding-inline-start: 6px; + + font: menu; + font-size: 13px; + font-style: normal; + font-weight: 510; + line-height: normal; + } + } + } +} diff --git a/web/menu.js b/web/menu.js new file mode 100644 index 000000000..508c2c303 --- /dev/null +++ b/web/menu.js @@ -0,0 +1,216 @@ +/* Copyright 2025 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { noContextMenu, stopEvent } from "pdfjs-lib"; + +class Menu { + #triggeringButton; + + #menu; + + #menuItems; + + #openMenuAC = null; + + #menuAC = new AbortController(); + + #lastIndex = -1; + + /** + * Create a menu for the given button. + * @param {HTMLElement} menuContainer + * @param {HTMLElement} triggeringButton + * @param {Array|null} menuItems + */ + constructor(menuContainer, triggeringButton, menuItems) { + this.#menu = menuContainer; + this.#triggeringButton = triggeringButton; + if (Array.isArray(menuItems)) { + this.#menuItems = menuItems; + } else { + this.#menuItems = []; + for (const button of this.#menu.querySelectorAll("button")) { + this.#menuItems.push(button); + } + } + this.#setUpMenu(); + } + + /** + * Close the menu. + */ + #closeMenu() { + if (!this.#openMenuAC) { + return; + } + const menu = this.#menu; + menu.classList.toggle("hidden", true); + this.#triggeringButton.ariaExpanded = "false"; + this.#openMenuAC.abort(); + this.#openMenuAC = null; + if (menu.contains(document.activeElement)) { + // If the menu is closed while focused, focus the actions button. + setTimeout(() => { + if (!menu.contains(document.activeElement)) { + this.#triggeringButton.focus(); + } + }, 0); + } + this.#lastIndex = -1; + } + + /** + * Set up the menu. + */ + #setUpMenu() { + this.#triggeringButton.addEventListener("click", e => { + if (this.#openMenuAC) { + this.#closeMenu(); + return; + } + + const menu = this.#menu; + menu.classList.toggle("hidden", false); + this.#triggeringButton.ariaExpanded = "true"; + this.#openMenuAC = new AbortController(); + const signal = AbortSignal.any([ + this.#menuAC.signal, + this.#openMenuAC.signal, + ]); + window.addEventListener( + "pointerdown", + ({ target }) => { + if (target !== this.#triggeringButton && !menu.contains(target)) { + this.#closeMenu(); + } + }, + { signal } + ); + window.addEventListener("blur", this.#closeMenu.bind(this), { signal }); + }); + + const { signal } = this.#menuAC; + + this.#menu.addEventListener( + "keydown", + e => { + switch (e.key) { + case "Escape": + this.#closeMenu(); + stopEvent(e); + break; + case "ArrowDown": + case "Tab": + this.#goToNextItem(e.target, true); + stopEvent(e); + break; + case "ArrowUp": + case "ShiftTab": + this.#goToNextItem(e.target, false); + stopEvent(e); + break; + case "Home": + this.#menuItems + .find( + item => !item.disabled && !item.classList.contains("hidden") + ) + .focus(); + stopEvent(e); + break; + case "End": + this.#menuItems + .findLast( + item => !item.disabled && !item.classList.contains("hidden") + ) + .focus(); + stopEvent(e); + break; + } + }, + { signal, capture: true } + ); + this.#menu.addEventListener("contextmenu", noContextMenu, { signal }); + this.#menu.addEventListener("click", this.#closeMenu.bind(this), { + signal, + capture: true, + }); + this.#triggeringButton.addEventListener( + "keydown", + ev => { + if (!this.#openMenuAC) { + return; + } + switch (ev.key) { + case "ArrowDown": + case "Home": + this.#menuItems + .find( + item => !item.disabled && !item.classList.contains("hidden") + ) + .focus(); + stopEvent(ev); + break; + case "ArrowUp": + case "End": + this.#menuItems + .findLast( + item => !item.disabled && !item.classList.contains("hidden") + ) + .focus(); + stopEvent(ev); + break; + case "Escape": + this.#closeMenu(); + stopEvent(ev); + } + }, + { signal } + ); + } + + /** + * Go to the next/previous menu item. + * @param {HTMLElement} element + * @param {boolean} forward + */ + #goToNextItem(element, forward) { + const index = + this.#lastIndex === -1 + ? this.#menuItems.indexOf(element) + : this.#lastIndex; + const len = this.#menuItems.length; + const increment = forward ? 1 : len - 1; + for ( + let i = (index + increment) % len; + i !== index; + i = (i + increment) % len + ) { + const menuItem = this.#menuItems[i]; + if (!menuItem.disabled && !menuItem.classList.contains("hidden")) { + menuItem.focus(); + this.#lastIndex = i; + break; + } + } + } + + destroy() { + this.#closeMenu(); + this.#menuAC?.abort(); + this.#menuAC = null; + } +} + +export { Menu }; diff --git a/web/pdf_viewer.css b/web/pdf_viewer.css index 66990399a..b4a5052f3 100644 --- a/web/pdf_viewer.css +++ b/web/pdf_viewer.css @@ -20,6 +20,7 @@ /* Ignored in GECKOVIEW builds: */ @import url(annotation_editor_layer_builder.css); @import url(sidebar.css); +@import url(menu.css); :root { color-scheme: light dark;