Skip to content

Commit 3098443

Browse files
authored
Merge pull request #20483 from calixteman/menu
Add a menu class in order to be used in the new UI for the merge feature
2 parents f29e6a9 + 4c6cc0a commit 3098443

3 files changed

Lines changed: 394 additions & 0 deletions

File tree

web/menu.css

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/* Copyright 2025 Mozilla Foundation
2+
*
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
.popupMenu {
17+
--menuitem-checkmark-icon: url(images/checkmark.svg);
18+
--menu-mark-icon-size: 0;
19+
--menu-icon-size: 16px;
20+
--menuitem-gap: 5px;
21+
--menuitem-border-color: transparent;
22+
--menuitem-active-bg: color-mix(
23+
in srgb,
24+
var(--menu-text-color),
25+
transparent 79%
26+
);
27+
--menuitem-text-active-fg: var(--menu-text-color);
28+
--menuitem-focus-bg: color-mix(
29+
in srgb,
30+
var(--menu-text-color),
31+
transparent 93%
32+
);
33+
--menuitem-focus-outline-color: light-dark(#0062fa, #00cadb);
34+
--menuitem-focus-border-color: light-dark(white, black);
35+
36+
@media screen and (forced-colors: active) {
37+
--menu-bg: Canvas;
38+
--menu-background-blend-mode: normal;
39+
--menu-box-shadow: none;
40+
--menu-backdrop-filter: none;
41+
--menu-text-color: ButtonText;
42+
--menu-border-color: CanvasText;
43+
--menuitem-border-color: none;
44+
--menuitem-hover-bg: SelectedItemText;
45+
--menuitem-text-hover-fg: SelectedItem;
46+
--menuitem-active-bg: SelectedItemText;
47+
--menuitem-text-active-fg: SelectedItem;
48+
--menuitem-focus-outline-color: CanvasText;
49+
--menuitem-focus-border-color: none;
50+
}
51+
52+
display: flex;
53+
flex-direction: column;
54+
width: max-content;
55+
height: auto;
56+
position: relative;
57+
left: 0;
58+
top: 1px;
59+
margin: 0;
60+
padding: 5px;
61+
62+
background: var(--menu-bg);
63+
background-blend-mode: var(--menu-background-blend-mode);
64+
box-shadow: var(--menu-box-shadow);
65+
border-radius: 6px;
66+
border: 1px solid var(--menu-border-color);
67+
backdrop-filter: var(--menu-backdrop-filter);
68+
69+
&.withMark {
70+
--menu-mark-icon-size: 16px;
71+
}
72+
73+
> li {
74+
display: flex;
75+
align-items: center;
76+
list-style: none;
77+
width: 100%;
78+
height: 24px;
79+
padding-inline: calc(var(--menu-mark-icon-size) + var(--menuitem-gap))
80+
var(--menuitem-gap);
81+
gap: var(--menuitem-gap);
82+
box-sizing: border-box;
83+
border-radius: var(--menuitem-border-radius);
84+
border: 1px solid var(--menuitem-border-color);
85+
background: transparent;
86+
87+
&:has(button.selected)::before {
88+
content: "";
89+
display: inline-block;
90+
width: 11px;
91+
height: 11px;
92+
mask-repeat: no-repeat;
93+
mask-position: center;
94+
mask-image: var(--menuitem-checkmark-icon);
95+
background-color: var(--menu-text-color);
96+
position: absolute;
97+
margin-left: -16px;
98+
}
99+
100+
&:has(button:disabled) {
101+
opacity: 0.62;
102+
pointer-events: none;
103+
}
104+
105+
&:hover {
106+
background: var(--menuitem-hover-bg);
107+
background-blend-mode: var(--menuitem-hover-background-blend-mode);
108+
> button {
109+
&:not(.noIcon)::before {
110+
background-color: var(--menuitem-text-hover-fg);
111+
}
112+
> span {
113+
color: var(--menuitem-text-hover-fg);
114+
}
115+
}
116+
&:has(button.selected)::before {
117+
background-color: var(--menuitem-text-hover-fg);
118+
}
119+
}
120+
121+
&:active {
122+
background-color: var(--menuitem-active-bg);
123+
> button > span {
124+
color: var(--menuitem-text-active-fg);
125+
}
126+
}
127+
128+
&:has(> button:focus-visible) {
129+
border-color: var(--menuitem-focus-border-color);
130+
background-color: var(--menuitem-focus-bg);
131+
outline: 2px solid var(--menuitem-focus-outline-color);
132+
outline-offset: 2px;
133+
}
134+
135+
> button {
136+
display: flex;
137+
flex-direction: row;
138+
align-items: center;
139+
width: 100%;
140+
height: auto;
141+
padding: var(--menuitem-gap);
142+
gap: var(--menuitem-gap);
143+
background: transparent;
144+
border: none;
145+
146+
&:not(.noIcon)::before {
147+
display: inline-block;
148+
width: var(--menu-icon-size);
149+
height: var(--menu-icon-size);
150+
content: "";
151+
mask-size: cover;
152+
mask-position: center;
153+
background-color: var(--menu-text-color);
154+
}
155+
156+
&:focus-visible {
157+
outline: none;
158+
}
159+
160+
> span {
161+
display: inline-block;
162+
width: max-content;
163+
height: auto;
164+
text-align: left;
165+
color: var(--menu-text-color);
166+
user-select: none;
167+
padding-inline-start: 6px;
168+
169+
font: menu;
170+
font-size: 13px;
171+
font-style: normal;
172+
font-weight: 510;
173+
line-height: normal;
174+
}
175+
}
176+
}
177+
}

web/menu.js

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/* Copyright 2025 Mozilla Foundation
2+
*
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
import { noContextMenu, stopEvent } from "pdfjs-lib";
17+
18+
class Menu {
19+
#triggeringButton;
20+
21+
#menu;
22+
23+
#menuItems;
24+
25+
#openMenuAC = null;
26+
27+
#menuAC = new AbortController();
28+
29+
#lastIndex = -1;
30+
31+
/**
32+
* Create a menu for the given button.
33+
* @param {HTMLElement} menuContainer
34+
* @param {HTMLElement} triggeringButton
35+
* @param {Array<HTMLElement>|null} menuItems
36+
*/
37+
constructor(menuContainer, triggeringButton, menuItems) {
38+
this.#menu = menuContainer;
39+
this.#triggeringButton = triggeringButton;
40+
if (Array.isArray(menuItems)) {
41+
this.#menuItems = menuItems;
42+
} else {
43+
this.#menuItems = [];
44+
for (const button of this.#menu.querySelectorAll("button")) {
45+
this.#menuItems.push(button);
46+
}
47+
}
48+
this.#setUpMenu();
49+
}
50+
51+
/**
52+
* Close the menu.
53+
*/
54+
#closeMenu() {
55+
if (!this.#openMenuAC) {
56+
return;
57+
}
58+
const menu = this.#menu;
59+
menu.classList.toggle("hidden", true);
60+
this.#triggeringButton.ariaExpanded = "false";
61+
this.#openMenuAC.abort();
62+
this.#openMenuAC = null;
63+
if (menu.contains(document.activeElement)) {
64+
// If the menu is closed while focused, focus the actions button.
65+
setTimeout(() => {
66+
if (!menu.contains(document.activeElement)) {
67+
this.#triggeringButton.focus();
68+
}
69+
}, 0);
70+
}
71+
this.#lastIndex = -1;
72+
}
73+
74+
/**
75+
* Set up the menu.
76+
*/
77+
#setUpMenu() {
78+
this.#triggeringButton.addEventListener("click", e => {
79+
if (this.#openMenuAC) {
80+
this.#closeMenu();
81+
return;
82+
}
83+
84+
const menu = this.#menu;
85+
menu.classList.toggle("hidden", false);
86+
this.#triggeringButton.ariaExpanded = "true";
87+
this.#openMenuAC = new AbortController();
88+
const signal = AbortSignal.any([
89+
this.#menuAC.signal,
90+
this.#openMenuAC.signal,
91+
]);
92+
window.addEventListener(
93+
"pointerdown",
94+
({ target }) => {
95+
if (target !== this.#triggeringButton && !menu.contains(target)) {
96+
this.#closeMenu();
97+
}
98+
},
99+
{ signal }
100+
);
101+
window.addEventListener("blur", this.#closeMenu.bind(this), { signal });
102+
});
103+
104+
const { signal } = this.#menuAC;
105+
106+
this.#menu.addEventListener(
107+
"keydown",
108+
e => {
109+
switch (e.key) {
110+
case "Escape":
111+
this.#closeMenu();
112+
stopEvent(e);
113+
break;
114+
case "ArrowDown":
115+
case "Tab":
116+
this.#goToNextItem(e.target, true);
117+
stopEvent(e);
118+
break;
119+
case "ArrowUp":
120+
case "ShiftTab":
121+
this.#goToNextItem(e.target, false);
122+
stopEvent(e);
123+
break;
124+
case "Home":
125+
this.#menuItems
126+
.find(
127+
item => !item.disabled && !item.classList.contains("hidden")
128+
)
129+
.focus();
130+
stopEvent(e);
131+
break;
132+
case "End":
133+
this.#menuItems
134+
.findLast(
135+
item => !item.disabled && !item.classList.contains("hidden")
136+
)
137+
.focus();
138+
stopEvent(e);
139+
break;
140+
}
141+
},
142+
{ signal, capture: true }
143+
);
144+
this.#menu.addEventListener("contextmenu", noContextMenu, { signal });
145+
this.#menu.addEventListener("click", this.#closeMenu.bind(this), {
146+
signal,
147+
capture: true,
148+
});
149+
this.#triggeringButton.addEventListener(
150+
"keydown",
151+
ev => {
152+
if (!this.#openMenuAC) {
153+
return;
154+
}
155+
switch (ev.key) {
156+
case "ArrowDown":
157+
case "Home":
158+
this.#menuItems
159+
.find(
160+
item => !item.disabled && !item.classList.contains("hidden")
161+
)
162+
.focus();
163+
stopEvent(ev);
164+
break;
165+
case "ArrowUp":
166+
case "End":
167+
this.#menuItems
168+
.findLast(
169+
item => !item.disabled && !item.classList.contains("hidden")
170+
)
171+
.focus();
172+
stopEvent(ev);
173+
break;
174+
case "Escape":
175+
this.#closeMenu();
176+
stopEvent(ev);
177+
}
178+
},
179+
{ signal }
180+
);
181+
}
182+
183+
/**
184+
* Go to the next/previous menu item.
185+
* @param {HTMLElement} element
186+
* @param {boolean} forward
187+
*/
188+
#goToNextItem(element, forward) {
189+
const index =
190+
this.#lastIndex === -1
191+
? this.#menuItems.indexOf(element)
192+
: this.#lastIndex;
193+
const len = this.#menuItems.length;
194+
const increment = forward ? 1 : len - 1;
195+
for (
196+
let i = (index + increment) % len;
197+
i !== index;
198+
i = (i + increment) % len
199+
) {
200+
const menuItem = this.#menuItems[i];
201+
if (!menuItem.disabled && !menuItem.classList.contains("hidden")) {
202+
menuItem.focus();
203+
this.#lastIndex = i;
204+
break;
205+
}
206+
}
207+
}
208+
209+
destroy() {
210+
this.#closeMenu();
211+
this.#menuAC?.abort();
212+
this.#menuAC = null;
213+
}
214+
}
215+
216+
export { Menu };

0 commit comments

Comments
 (0)