react-x11 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- package/src/scene3d.js +0 -683
package/src/components/Menu.js
CHANGED
|
@@ -2,18 +2,41 @@
|
|
|
2
2
|
// support needed. Plain createElement (no JSX) so the library stays
|
|
3
3
|
// build-step-free for consumers.
|
|
4
4
|
|
|
5
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
6
|
-
import {
|
|
5
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import {
|
|
7
|
+
capBand,
|
|
8
|
+
capTrim,
|
|
9
|
+
rowRadius,
|
|
10
|
+
useDirection,
|
|
11
|
+
useTheme,
|
|
12
|
+
} from './theme.js';
|
|
13
|
+
import { Icon, iconSize } from './Icon.js';
|
|
7
14
|
import {
|
|
8
15
|
DEFAULT_LABEL_SIZE,
|
|
16
|
+
anchorArea,
|
|
9
17
|
anchorRect,
|
|
10
18
|
measureLabel,
|
|
11
19
|
movingToward,
|
|
12
20
|
SAFE_HOVER_DELAY,
|
|
13
|
-
screenOf,
|
|
14
21
|
screenPoint,
|
|
22
|
+
useAnchorTracking,
|
|
23
|
+
useDismissOnWindowBlur,
|
|
15
24
|
} from './anchor.js';
|
|
16
25
|
import { typeAheadChar, useTypeAhead } from './typeahead.js';
|
|
26
|
+
import { useGlobalMenu } from '../globalmenu.js';
|
|
27
|
+
import { acceleratedItem } from '../accelerators.js';
|
|
28
|
+
import { useAcceleratorEntry } from '../acceleratorhooks.js';
|
|
29
|
+
import { useTopLevelWindow } from '../windowid.js';
|
|
30
|
+
import {
|
|
31
|
+
ariaKeyShortcuts,
|
|
32
|
+
checkShortcut,
|
|
33
|
+
formatShortcut,
|
|
34
|
+
hasSubmenu,
|
|
35
|
+
isEnabled,
|
|
36
|
+
isSelectable,
|
|
37
|
+
isSeparator,
|
|
38
|
+
visibleItems,
|
|
39
|
+
} from '../menuitem.js';
|
|
17
40
|
import {
|
|
18
41
|
XK_DOWN,
|
|
19
42
|
XK_END,
|
|
@@ -29,53 +52,163 @@ import {
|
|
|
29
52
|
|
|
30
53
|
const h = React.createElement;
|
|
31
54
|
|
|
32
|
-
|
|
55
|
+
// A row is its label with even space all round: the same number left and
|
|
56
|
+
// right as above and below, measured to the letters rather than to the
|
|
57
|
+
// font's line box (`capTrim`). Which is why the height is derived — hard-code
|
|
58
|
+
// it and the vertical space is whatever the ascent happened to leave.
|
|
59
|
+
const MENU_ITEM_PAD = 8;
|
|
60
|
+
const menuRowHeight = (fontSize) => capBand(fontSize) + MENU_ITEM_PAD * 2;
|
|
33
61
|
|
|
34
62
|
const MENU_SEPARATOR_HEIGHT = 7;
|
|
35
63
|
|
|
36
64
|
const MENU_MIN_WIDTH = 140;
|
|
37
65
|
|
|
66
|
+
// The inset between the popup's edge and a row, which is what makes the
|
|
67
|
+
// highlight read as a *pill on* the menu rather than a band across it — and
|
|
68
|
+
// what keeps the first and last one clear of the sheet's rounded corners,
|
|
69
|
+
// where a full-width highlight would show a square shoulder outside the
|
|
70
|
+
// curve. It is also half of a pair: the pill's own radius is derived from it
|
|
71
|
+
// so the two corners share a centre (`rowRadius`).
|
|
38
72
|
const MENU_PAD = 4;
|
|
39
73
|
|
|
74
|
+
// A hairline, not `theme.borderWidth`: this border is there to give the sheet
|
|
75
|
+
// an edge where it meets the desktop behind it, and a theme that draws 2px
|
|
76
|
+
// borders on its *controls* does not mean a 2px outline around every menu.
|
|
77
|
+
const MENU_BORDER = 1;
|
|
78
|
+
|
|
79
|
+
// How far a bar item's pill sits inside the bar, taken out of its padding so
|
|
80
|
+
// the bar's height does not change. The strip carries the same inset at its
|
|
81
|
+
// two ends: a pill that starts in the window's own corner reads as part of
|
|
82
|
+
// the frame rather than as something on a strip, and the first menu is the
|
|
83
|
+
// one every pointer arrives at.
|
|
84
|
+
const BAR_INSET = 3;
|
|
85
|
+
// the gap between two pills, split between them
|
|
86
|
+
const BAR_GAP = 1;
|
|
87
|
+
|
|
88
|
+
// A bar item wears the same pill as a row in the menu it opens, so it takes
|
|
89
|
+
// the row's padding rather than numbers of its own: a title packed tighter
|
|
90
|
+
// than its own first row is the tell that the two were measured separately.
|
|
91
|
+
// Vertically that is `MENU_ITEM_PAD` exactly — same padding, same `capTrim`
|
|
92
|
+
// text — which makes the pill `menuRowHeight` tall, and the bar that much
|
|
93
|
+
// taller for it.
|
|
94
|
+
//
|
|
95
|
+
// Horizontally it takes a little more. A row's label is not `MENU_ITEM_PAD`
|
|
96
|
+
// from the pill's edge but a whole `MENU_GUTTER` in, past the check column,
|
|
97
|
+
// so matching the row's padding here would read as the cramped one: on a
|
|
98
|
+
// strip the pills sit shoulder to shoulder, with nothing but that padding
|
|
99
|
+
// between one label and the next.
|
|
100
|
+
const BAR_ITEM_PAD_X = MENU_ITEM_PAD + 4;
|
|
101
|
+
|
|
102
|
+
// The bar entry that stands for the titles that did not fit. A symbol rather
|
|
103
|
+
// than a `label`, because it is the one entry on the bar the application did
|
|
104
|
+
// not write: everything else about it — its popup, its rows, its keys — is
|
|
105
|
+
// the ordinary machinery, and a marker no menu object can collide with is
|
|
106
|
+
// what keeps it that way.
|
|
107
|
+
const OVERFLOW = Symbol('menubar overflow');
|
|
108
|
+
const isOverflow = (menu) => Boolean(menu?.[OVERFLOW]);
|
|
109
|
+
// A title is keyed by its label; the chevron has none, and a NUL is the one
|
|
110
|
+
// key no label can equal — so a menu actually called "More" keeps its
|
|
111
|
+
// identity across a resize instead of trading places with the chevron.
|
|
112
|
+
const OVERFLOW_KEY = '\0menubar-overflow';
|
|
113
|
+
|
|
114
|
+
// The horizontal gap between a menu and the submenu it opens, measured from
|
|
115
|
+
// the parent popup's outer edge — so `0` is flush against it, a positive
|
|
116
|
+
// value leaves the desktop showing between the two, and a negative one
|
|
117
|
+
// overlaps the parent the way the classic toolkits do. The pointer crosses
|
|
118
|
+
// this gap on its way to the submenu and the safe polygon covers it either
|
|
119
|
+
// way (`movingToward`), so this is a matter of taste rather than of reach.
|
|
120
|
+
const SUBMENU_GAP = 0;
|
|
121
|
+
|
|
40
122
|
const MENU_GUTTER = 24; // room for the check column
|
|
123
|
+
// what a self-drawing icon gets to fill, inside that column's 16px
|
|
124
|
+
const MENU_ICON_SIZE = 12;
|
|
41
125
|
|
|
42
126
|
const MENU_SHORTCUT_GAP = 24;
|
|
43
127
|
// menus size to their content rather than scrolling, so a page is a fixed
|
|
44
128
|
// stride — deriving one from the menu height would just equal Home/End
|
|
45
129
|
const MENU_PAGE_ROWS = 10;
|
|
46
130
|
|
|
47
|
-
const isSelectable = (item) => item && !item.separator && !item.disabled;
|
|
48
|
-
|
|
49
131
|
/** Total popup height for a menu's items (separators are shorter). */
|
|
50
|
-
function menuListHeight(items) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
132
|
+
function menuListHeight(items, fontSize) {
|
|
133
|
+
const row = menuRowHeight(fontSize);
|
|
134
|
+
const body = visibleItems(items).reduce(
|
|
135
|
+
(sum, item) => sum + (isSeparator(item) ? MENU_SEPARATOR_HEIGHT : row),
|
|
54
136
|
0,
|
|
55
137
|
);
|
|
56
|
-
return body + MENU_PAD
|
|
138
|
+
return body + (MENU_PAD + MENU_BORDER) * 2;
|
|
57
139
|
}
|
|
58
140
|
|
|
59
141
|
/** Widest label + shortcut, measured, so the popup can be sized up front. */
|
|
60
142
|
function menuListWidth(node, items, fontSize) {
|
|
61
143
|
let widest = 0;
|
|
62
|
-
for (const item of items) {
|
|
63
|
-
if (item
|
|
144
|
+
for (const item of visibleItems(items)) {
|
|
145
|
+
if (isSeparator(item)) continue;
|
|
64
146
|
const label = measureLabel(node, item.label ?? '', {
|
|
65
147
|
size: fontSize,
|
|
66
148
|
}).width;
|
|
67
|
-
const
|
|
68
|
-
|
|
149
|
+
const accelerator = formatShortcut(item.shortcut);
|
|
150
|
+
const shortcut = accelerator
|
|
151
|
+
? measureLabel(node, accelerator, { size: fontSize }).width +
|
|
69
152
|
MENU_SHORTCUT_GAP
|
|
70
153
|
: 0;
|
|
71
154
|
widest = Math.max(widest, label + shortcut);
|
|
72
155
|
}
|
|
73
156
|
return Math.max(
|
|
74
157
|
MENU_MIN_WIDTH,
|
|
75
|
-
Math.ceil(widest) +
|
|
158
|
+
Math.ceil(widest) +
|
|
159
|
+
MENU_GUTTER +
|
|
160
|
+
(MENU_PAD + MENU_BORDER) * 2 +
|
|
161
|
+
MENU_ITEM_PAD +
|
|
162
|
+
2,
|
|
76
163
|
);
|
|
77
164
|
}
|
|
78
165
|
|
|
166
|
+
/**
|
|
167
|
+
* How much of the bar one title takes, margins included — the same
|
|
168
|
+
* arithmetic the bar itself is laid out with, written next to
|
|
169
|
+
* `menuListWidth` so the two cannot drift.
|
|
170
|
+
*/
|
|
171
|
+
function barItemWidth(node, label, fontSize) {
|
|
172
|
+
const text = measureLabel(node, label ?? '', { size: fontSize }).width;
|
|
173
|
+
return Math.ceil(text) + (BAR_ITEM_PAD_X + BAR_GAP) * 2;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The same, for the chevron: an icon box where a title has its label. */
|
|
177
|
+
const barOverflowWidth = (fontSize) =>
|
|
178
|
+
iconSize(fontSize) + (BAR_ITEM_PAD_X + BAR_GAP) * 2;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* How many titles the bar can paint, and therefore where it is cut. The rest
|
|
182
|
+
* go to the chevron.
|
|
183
|
+
*
|
|
184
|
+
* Two passes rather than Qt's one. Qt reserves the extension button's width
|
|
185
|
+
* unconditionally, which spends it on every bar including the ones that
|
|
186
|
+
* never overflow; asking first whether everything fits costs one extra sum
|
|
187
|
+
* and gives the common case its full width back. It cannot oscillate — the
|
|
188
|
+
* second pass only ever runs on a bar already known not to fit, and the
|
|
189
|
+
* chevron is narrower than the title it displaces — which is the trap this
|
|
190
|
+
* shape is usually avoided for.
|
|
191
|
+
*
|
|
192
|
+
* A title that is *partly* visible does not count as visible: half a word is
|
|
193
|
+
* not a menu you can find, and the pointer cannot reach the half that is
|
|
194
|
+
* off-window anyway.
|
|
195
|
+
*/
|
|
196
|
+
function barCut(node, menus, fontSize, width) {
|
|
197
|
+
const widths = menus.map((menu) => barItemWidth(node, menu.label, fontSize));
|
|
198
|
+
const inner = width - (BAR_INSET - BAR_GAP) * 2;
|
|
199
|
+
const total = widths.reduce((sum, w) => sum + w, 0);
|
|
200
|
+
if (total <= inner) return menus.length;
|
|
201
|
+
const room = inner - barOverflowWidth(fontSize);
|
|
202
|
+
let used = 0;
|
|
203
|
+
let count = 0;
|
|
204
|
+
for (const w of widths) {
|
|
205
|
+
if (used + w > room) break;
|
|
206
|
+
used += w;
|
|
207
|
+
count++;
|
|
208
|
+
}
|
|
209
|
+
return count;
|
|
210
|
+
}
|
|
211
|
+
|
|
79
212
|
/** Next selectable index in `dir`, wrapping, skipping separators/disabled. */
|
|
80
213
|
function nextSelectable(items, from, dir) {
|
|
81
214
|
const n = items.length;
|
|
@@ -87,9 +220,96 @@ function nextSelectable(items, from, dir) {
|
|
|
87
220
|
return -1;
|
|
88
221
|
}
|
|
89
222
|
|
|
223
|
+
/**
|
|
224
|
+
* dbusmenu's `toggle-state` as ARIA's, which happens to have the same three.
|
|
225
|
+
*
|
|
226
|
+
* That correspondence is the reason `-1` is worth carrying rather than
|
|
227
|
+
* flattening to off: `'mixed'` is how a screen reader says "on for part of
|
|
228
|
+
* the selection", and it has no other way to hear it.
|
|
229
|
+
*/
|
|
230
|
+
function ariaChecked(state) {
|
|
231
|
+
if (state === 1) return true;
|
|
232
|
+
if (state === -1) return 'mixed';
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The glyph for a `toggleType`/`toggleState` pair, or `null` for an item that
|
|
238
|
+
* is not a toggle at all.
|
|
239
|
+
*
|
|
240
|
+
* Three states rather than two, because dbusmenu has three and the third is
|
|
241
|
+
* not "off": `-1` is **indeterminate** — a "Bold" that is on for part of the
|
|
242
|
+
* selection — and drawing it as unchecked would answer a question the item is
|
|
243
|
+
* explicitly declining to answer. GTK draws a dash for it, so a dash it is.
|
|
244
|
+
*
|
|
245
|
+
* An item whose `toggleState` is 0 draws nothing and keeps its gutter, which
|
|
246
|
+
* is what stops a menu of checkboxes from shuffling sideways as they are
|
|
247
|
+
* ticked.
|
|
248
|
+
*
|
|
249
|
+
* These are system icons rather than the `✓`, `●` and `–` they used to be:
|
|
250
|
+
* a text mark is only as good as the font it lands in, which is the exact
|
|
251
|
+
* warning this file gives applications about string `icon`s two doc comments
|
|
252
|
+
* down. Core taking its own advice is the point of the set.
|
|
253
|
+
*/
|
|
254
|
+
function toggleMark(item) {
|
|
255
|
+
if (!item.toggleType) return null;
|
|
256
|
+
if (item.toggleState === -1) return 'dash';
|
|
257
|
+
if (item.toggleState !== 1) return null;
|
|
258
|
+
return item.toggleType === 'radio' ? 'dot' : 'check';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* What goes in the column to the left of the label: a toggle mark, or the
|
|
263
|
+
* item's `icon`.
|
|
264
|
+
*
|
|
265
|
+
* One column for both, because that is the choice every desktop toolkit
|
|
266
|
+
* makes and the reason is layout rather than taste — a second column would
|
|
267
|
+
* indent every label in the menu to reserve room for icons that most items
|
|
268
|
+
* do not have. An item that is both checked and iconned shows the check,
|
|
269
|
+
* since the check is state and the icon is only identity.
|
|
270
|
+
*
|
|
271
|
+
* Three forms, and the third is the one to reach for. A **string** is drawn
|
|
272
|
+
* as text, which is a one-liner but only as good as the font: `✂` and `⏻`
|
|
273
|
+
* are tofu in Arial, and a menu of empty boxes is worse than a menu with no
|
|
274
|
+
* icons at all. An **element** renders as-is. A **function** is called with
|
|
275
|
+
* the colour the row's label is being drawn in and the size the gutter
|
|
276
|
+
* allows — which is what a `<canvas onDraw>` icon needs, since it has to
|
|
277
|
+
* pick a stroke colour and nothing else can tell it whether its row is
|
|
278
|
+
* highlighted, disabled or at rest.
|
|
279
|
+
*/
|
|
280
|
+
function gutterMark(item, { color, fontSize }) {
|
|
281
|
+
const toggle = toggleMark(item);
|
|
282
|
+
// no `color` on the built-ins: the row names its ink and both of these
|
|
283
|
+
// inherit it, which is also what keeps them in step with `:active`
|
|
284
|
+
if (toggle) return h(Icon, { name: toggle, size: MENU_ICON_SIZE });
|
|
285
|
+
const { icon } = item;
|
|
286
|
+
if (icon == null) return null;
|
|
287
|
+
if (typeof icon === 'string' || typeof icon === 'number') {
|
|
288
|
+
return h('text', { style: { fontSize } }, String(icon));
|
|
289
|
+
}
|
|
290
|
+
return typeof icon === 'function'
|
|
291
|
+
? icon({ color, size: MENU_ICON_SIZE })
|
|
292
|
+
: icon;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* How a row is drawn, which is three states and not two.
|
|
297
|
+
*
|
|
298
|
+
* `'active'` is the selection: this row is where the menus are being driven
|
|
299
|
+
* from. `'path'` is a row whose submenu has taken that over — it is still
|
|
300
|
+
* the way back to where you are, and it still has to look chosen, but two
|
|
301
|
+
* selection-coloured rows in two menus would be claiming the same thing
|
|
302
|
+
* twice. Every desktop resolves that the same way: the trail goes quiet and
|
|
303
|
+
* only the live end of it stays lit.
|
|
304
|
+
*/
|
|
305
|
+
function rowState(index, active, handedOn) {
|
|
306
|
+
if (index !== active) return undefined;
|
|
307
|
+
return handedOn ? 'path' : 'active';
|
|
308
|
+
}
|
|
309
|
+
|
|
90
310
|
function MenuRow({
|
|
91
311
|
item,
|
|
92
|
-
|
|
312
|
+
state,
|
|
93
313
|
onHover,
|
|
94
314
|
onMove,
|
|
95
315
|
onSelect,
|
|
@@ -97,71 +317,128 @@ function MenuRow({
|
|
|
97
317
|
nodeRef,
|
|
98
318
|
}) {
|
|
99
319
|
const theme = useTheme();
|
|
100
|
-
|
|
320
|
+
const rtl = useDirection() === 'rtl';
|
|
321
|
+
// only the live end of the trail takes the selection colour, and with it
|
|
322
|
+
// the inverted label
|
|
323
|
+
const active = state === 'active';
|
|
324
|
+
if (isSeparator(item)) {
|
|
101
325
|
return h(
|
|
102
326
|
'box',
|
|
103
|
-
{
|
|
104
|
-
|
|
327
|
+
{
|
|
328
|
+
theme,
|
|
329
|
+
role: 'separator',
|
|
330
|
+
style: { height: MENU_SEPARATOR_HEIGHT, justifyContent: 'center' },
|
|
331
|
+
},
|
|
332
|
+
h('box', { style: { height: 1, backgroundColor: theme.border } }),
|
|
105
333
|
);
|
|
106
334
|
}
|
|
107
|
-
const dim = item
|
|
108
|
-
const
|
|
335
|
+
const dim = !isEnabled(item);
|
|
336
|
+
const submenu = hasSubmenu(item);
|
|
337
|
+
const accelerator = formatShortcut(item.shortcut);
|
|
338
|
+
const rowInk = dim ? theme.textMuted : active ? theme.hoverText : theme.text;
|
|
339
|
+
if (process.env.NODE_ENV !== 'production') checkShortcut(item);
|
|
109
340
|
return h(
|
|
110
341
|
'box',
|
|
111
342
|
{
|
|
343
|
+
// dbusmenu's three toggle states map onto ARIA's three exactly, which
|
|
344
|
+
// is the reason `toggleState: -1` is worth carrying: `'mixed'` is how a
|
|
345
|
+
// screen reader says "on for part of the selection", and it has no
|
|
346
|
+
// other way to hear it.
|
|
347
|
+
role: item.toggleType
|
|
348
|
+
? item.toggleType === 'radio'
|
|
349
|
+
? 'menuitemradio'
|
|
350
|
+
: 'menuitemcheckbox'
|
|
351
|
+
: 'menuitem',
|
|
352
|
+
// the label alone: without it, name-from-contents would read the
|
|
353
|
+
// shortcut and the submenu arrow into the name ("New Ctrl+N")
|
|
354
|
+
'aria-label': typeof item.label === 'string' ? item.label : undefined,
|
|
355
|
+
'aria-keyshortcuts': ariaKeyShortcuts(item.shortcut),
|
|
356
|
+
'aria-checked': item.toggleType
|
|
357
|
+
? ariaChecked(item.toggleState)
|
|
358
|
+
: undefined,
|
|
359
|
+
'aria-haspopup': submenu ? 'menu' : undefined,
|
|
360
|
+
'aria-expanded': submenu ? state === 'path' : undefined,
|
|
361
|
+
disabled: dim || undefined,
|
|
112
362
|
ref: nodeRef,
|
|
113
|
-
height: MENU_ITEM_HEIGHT,
|
|
114
|
-
flexDirection: 'row',
|
|
115
|
-
alignItems: 'center',
|
|
116
|
-
paddingLeft: 8,
|
|
117
|
-
paddingRight: 8,
|
|
118
|
-
cursor: dim ? undefined : 'pointer',
|
|
119
|
-
backgroundColor: active ? theme.hoverBackground : theme.background,
|
|
120
363
|
onMouseEnter: dim ? undefined : onHover,
|
|
121
364
|
onMouseMove: dim ? undefined : onMove,
|
|
122
365
|
onClick: dim ? undefined : () => onSelect(item),
|
|
366
|
+
style: {
|
|
367
|
+
height: menuRowHeight(fontSize),
|
|
368
|
+
flexDirection: 'row',
|
|
369
|
+
alignItems: 'center',
|
|
370
|
+
paddingLeft: MENU_ITEM_PAD,
|
|
371
|
+
paddingRight: MENU_ITEM_PAD,
|
|
372
|
+
cursor: dim ? undefined : 'pointer',
|
|
373
|
+
// A pill inside the sheet: the row is inset from the popup edge by
|
|
374
|
+
// the list's padding, and rounded so that its corner and the sheet's
|
|
375
|
+
// share a centre — the two curves are then one shape rather than two
|
|
376
|
+
// that nearly agree.
|
|
377
|
+
borderRadius: rowRadius(theme, MENU_BORDER, MENU_PAD),
|
|
378
|
+
// Nothing at rest: the sheet under it is already that colour, and
|
|
379
|
+
// now that the row is rounded, repainting it per row would be a
|
|
380
|
+
// coverage mask drawn to change nothing — with four corners it
|
|
381
|
+
// deliberately leaves out.
|
|
382
|
+
backgroundColor: active
|
|
383
|
+
? theme.hoverBackground
|
|
384
|
+
: state === 'path'
|
|
385
|
+
? theme.surfaceActive
|
|
386
|
+
: 'transparent',
|
|
387
|
+
// The row's ink, said once for the mark, the label, the accelerator
|
|
388
|
+
// and the submenu chevron — four elements that used to repeat this
|
|
389
|
+
// ternary and could each be forgotten separately.
|
|
390
|
+
color: rowInk,
|
|
391
|
+
// the item is already highlighted by the time it can be pressed, so
|
|
392
|
+
// the press is a further step down rather than a first one — without
|
|
393
|
+
// it the command runs on the release out of a picture that never
|
|
394
|
+
// changed
|
|
395
|
+
...(dim
|
|
396
|
+
? null
|
|
397
|
+
: { ':active': { backgroundColor: theme.accentActive } }),
|
|
398
|
+
},
|
|
123
399
|
},
|
|
124
400
|
h(
|
|
125
401
|
'box',
|
|
126
|
-
{ width: MENU_GUTTER -
|
|
127
|
-
item
|
|
128
|
-
h('text', {
|
|
129
|
-
color: active ? theme.hoverText : theme.text,
|
|
130
|
-
fontSize,
|
|
131
|
-
children: '\u2713',
|
|
132
|
-
}),
|
|
133
|
-
),
|
|
134
|
-
h(
|
|
135
|
-
'text',
|
|
136
|
-
{
|
|
137
|
-
color: dim ? theme.dim : active ? theme.hoverText : theme.text,
|
|
138
|
-
fontSize,
|
|
139
|
-
},
|
|
140
|
-
item.label,
|
|
402
|
+
{ style: { width: MENU_GUTTER - MENU_ITEM_PAD, alignItems: 'center' } },
|
|
403
|
+
gutterMark(item, { color: rowInk, fontSize }),
|
|
141
404
|
),
|
|
142
|
-
h('
|
|
143
|
-
|
|
405
|
+
h('text', { style: [capTrim, { fontSize }] }, item.label),
|
|
406
|
+
h('box', { style: { flexGrow: 1 } }),
|
|
407
|
+
// The accelerator and the chevron are quieter than the label at rest and
|
|
408
|
+
// rise with the row when it is chosen — so on an active row they say
|
|
409
|
+
// nothing and take what the row set.
|
|
410
|
+
accelerator &&
|
|
144
411
|
h(
|
|
145
412
|
'text',
|
|
146
413
|
{
|
|
147
|
-
|
|
148
|
-
fontSize,
|
|
414
|
+
style: [capTrim, { fontSize }, !active && { color: theme.textMuted }],
|
|
149
415
|
},
|
|
150
|
-
|
|
416
|
+
accelerator,
|
|
151
417
|
),
|
|
152
|
-
|
|
153
|
-
h(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
418
|
+
submenu &&
|
|
419
|
+
h(Icon, {
|
|
420
|
+
// points the way the submenu opens, which is away from the edge the
|
|
421
|
+
// rows begin at
|
|
422
|
+
name: rtl ? 'chevronLeft' : 'chevronRight',
|
|
423
|
+
// The capitals of the row, not the gutter's 16px column: a chevron
|
|
424
|
+
// stands as tall as its box, so `MENU_ICON_SIZE` would put an arrow
|
|
425
|
+
// beside the label taller than the label.
|
|
426
|
+
size: capBand(fontSize),
|
|
427
|
+
style: !active && { color: theme.textMuted },
|
|
157
428
|
}),
|
|
158
429
|
);
|
|
159
430
|
}
|
|
160
431
|
|
|
161
|
-
/**
|
|
432
|
+
/**
|
|
433
|
+
* Items at `depth`, walking `path` down through nested `items`.
|
|
434
|
+
*
|
|
435
|
+
* Hidden items are dropped **here**, at the single point every level is read
|
|
436
|
+
* through, so `path` indexes the rows that exist. Filtering at the draw call
|
|
437
|
+
* instead would leave the keyboard walking a list the eye cannot see.
|
|
438
|
+
*/
|
|
162
439
|
function levelItems(rootItems, path, depth) {
|
|
163
|
-
let items = rootItems;
|
|
164
|
-
for (let d = 0; d < depth; d++) items = items[path[d]]?.items
|
|
440
|
+
let items = visibleItems(rootItems);
|
|
441
|
+
for (let d = 0; d < depth; d++) items = visibleItems(items[path[d]]?.items);
|
|
165
442
|
return items;
|
|
166
443
|
}
|
|
167
444
|
|
|
@@ -187,10 +464,15 @@ function MenuLevel({
|
|
|
187
464
|
const theme = useTheme();
|
|
188
465
|
const items = levelItems(rootItems, path, depth);
|
|
189
466
|
const active = path[depth] ?? -1;
|
|
190
|
-
const childItems = items[active]?.items;
|
|
191
|
-
const childOpen = path.length > depth + 1 && childItems
|
|
467
|
+
const childItems = visibleItems(items[active]?.items);
|
|
468
|
+
const childOpen = path.length > depth + 1 && childItems.length > 0;
|
|
469
|
+
// Has this level's selection handed over to the one below it? A submenu
|
|
470
|
+
// opened with nothing selected in it yet has not: the pointer is still on
|
|
471
|
+
// the row that opened it, and that row is still where the keys go.
|
|
472
|
+
const handedOn = childOpen && (path[depth + 1] ?? -1) >= 0;
|
|
192
473
|
|
|
193
474
|
const activeRowRef = useRef(null);
|
|
475
|
+
const listRef = useRef(null);
|
|
194
476
|
const [childRect, setChildRect] = useState(null);
|
|
195
477
|
|
|
196
478
|
useEffect(() => {
|
|
@@ -204,12 +486,26 @@ function MenuLevel({
|
|
|
204
486
|
// anyway rather than anchoring off a zero rect
|
|
205
487
|
if (!node?.abs?.width) return;
|
|
206
488
|
setChildRect(
|
|
207
|
-
anchorRect(node, {
|
|
208
|
-
|
|
489
|
+
anchorRect(listRef.current ?? node, {
|
|
490
|
+
// the logical side: a submenu opens away from the edge its parent's
|
|
491
|
+
// rows begin at, and still flips at the screen edge from there
|
|
492
|
+
placement: 'end',
|
|
493
|
+
// The edge comes from the list box, which fills the popup, and the
|
|
494
|
+
// alignment from the row. Anchoring both to the row put the submenu
|
|
495
|
+
// a border and a padding *inside* its parent's right edge, so the
|
|
496
|
+
// two menus overlapped by five pixels.
|
|
497
|
+
alignTo: node,
|
|
209
498
|
align: 'start',
|
|
210
|
-
|
|
499
|
+
// Lined up on the *items*, not on the boxes: the submenu's own
|
|
500
|
+
// border and padding come before its first row, so a popup whose
|
|
501
|
+
// top edge is level with the parent row opens that row's
|
|
502
|
+
// continuation six pixels lower than the row itself. Shifting by
|
|
503
|
+
// the inset puts the first item exactly beside the item it came
|
|
504
|
+
// out of, which is where the eye is already looking.
|
|
505
|
+
alignOffset: -(MENU_BORDER + MENU_PAD),
|
|
506
|
+
offset: SUBMENU_GAP,
|
|
211
507
|
width: menuListWidth(node, childItems, fontSize),
|
|
212
|
-
height: menuListHeight(childItems),
|
|
508
|
+
height: menuListHeight(childItems, fontSize),
|
|
213
509
|
}),
|
|
214
510
|
);
|
|
215
511
|
}, [childOpen, active, depth, fontSize, rect.x, rect.y]);
|
|
@@ -236,7 +532,7 @@ function MenuLevel({
|
|
|
236
532
|
const applyHover = (index) => {
|
|
237
533
|
const base = [...path.slice(0, depth), index];
|
|
238
534
|
// hovering a parent row opens its submenu with nothing selected inside
|
|
239
|
-
setPath(items[index]
|
|
535
|
+
setPath(hasSubmenu(items[index]) ? [...base, -1] : base);
|
|
240
536
|
};
|
|
241
537
|
|
|
242
538
|
const hover = (index, ev) => {
|
|
@@ -274,7 +570,7 @@ function MenuLevel({
|
|
|
274
570
|
};
|
|
275
571
|
|
|
276
572
|
const choose = (item) => {
|
|
277
|
-
if (item
|
|
573
|
+
if (hasSubmenu(item)) {
|
|
278
574
|
setPath([...path.slice(0, depth), items.indexOf(item), -1]);
|
|
279
575
|
return;
|
|
280
576
|
}
|
|
@@ -284,35 +580,46 @@ function MenuLevel({
|
|
|
284
580
|
return h(
|
|
285
581
|
'popup',
|
|
286
582
|
{
|
|
583
|
+
theme,
|
|
287
584
|
x: rect.x,
|
|
288
585
|
y: rect.y,
|
|
289
586
|
width: rect.width,
|
|
290
587
|
height: rect.height,
|
|
291
588
|
windowType: 'popup_menu',
|
|
292
|
-
backgroundColor: theme.background,
|
|
293
|
-
// the root level holds the pointer grab for the whole menu: a press
|
|
294
|
-
// anywhere else — including this app's own window frame, which
|
|
295
|
-
// belongs to the window manager — closes it instead of vanishing
|
|
296
|
-
// into whatever was clicked. Submenus need no grab of their own;
|
|
297
|
-
// owner-events still delivers their presses to them.
|
|
298
589
|
grab: depth === 0,
|
|
299
590
|
onDismiss: depth === 0 ? onDismiss : undefined,
|
|
591
|
+
// ARGB where the display has it, so the corners the sheet gives up are
|
|
592
|
+
// the desktop rather than a colour. The window paints nothing itself
|
|
593
|
+
// when it can be seen through — the list box below is the whole of the
|
|
594
|
+
// menu, and a square fill under it would put the corners straight back.
|
|
595
|
+
// Without a compositor the window is the opaque rectangle it always
|
|
596
|
+
// was, and the list box's rounding is gated off to match.
|
|
597
|
+
transparent: true,
|
|
598
|
+
style: {
|
|
599
|
+
backgroundColor: theme.surface,
|
|
600
|
+
'@supports transparency': { backgroundColor: 'transparent' },
|
|
601
|
+
},
|
|
300
602
|
},
|
|
301
603
|
h(
|
|
302
604
|
'box',
|
|
303
605
|
{
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
606
|
+
ref: listRef,
|
|
607
|
+
role: 'menu',
|
|
608
|
+
style: {
|
|
609
|
+
flexGrow: 1,
|
|
610
|
+
flexShrink: 1,
|
|
611
|
+
padding: MENU_PAD,
|
|
612
|
+
borderWidth: MENU_BORDER,
|
|
613
|
+
borderColor: theme.border,
|
|
614
|
+
backgroundColor: theme.surface,
|
|
615
|
+
'@supports transparency': { borderRadius: theme.radiusPopup },
|
|
616
|
+
},
|
|
310
617
|
},
|
|
311
618
|
items.map((item, index) =>
|
|
312
619
|
h(MenuRow, {
|
|
313
|
-
key: item
|
|
620
|
+
key: isSeparator(item) ? `sep-${index}` : (item.key ?? item.label),
|
|
314
621
|
item,
|
|
315
|
-
|
|
622
|
+
state: rowState(index, active, handedOn),
|
|
316
623
|
fontSize,
|
|
317
624
|
nodeRef: index === active ? activeRowRef : undefined,
|
|
318
625
|
onHover: (ev) => hover(index, ev),
|
|
@@ -343,10 +650,13 @@ function MenuLevel({
|
|
|
343
650
|
*
|
|
344
651
|
* Returns true when the key was consumed. Left and Right fall through when
|
|
345
652
|
* there is no submenu to enter or leave, so `MenuBar` can walk the bar.
|
|
653
|
+
*
|
|
654
|
+
* `rtl` swaps the two horizontal keys, because they name what happens on the
|
|
655
|
+
* screen: a submenu that opens to the left is a submenu Left enters.
|
|
346
656
|
*/
|
|
347
657
|
function handleMenuKey(
|
|
348
658
|
ev,
|
|
349
|
-
{ rootItems, path, setPath, select, close, typeAhead },
|
|
659
|
+
{ rootItems, path, setPath, select, close, typeAhead, rtl = false },
|
|
350
660
|
) {
|
|
351
661
|
const depth = path.length - 1;
|
|
352
662
|
if (depth < 0) return false;
|
|
@@ -354,8 +664,8 @@ function handleMenuKey(
|
|
|
354
664
|
const active = path[depth];
|
|
355
665
|
const setActive = (i) => setPath([...path.slice(0, depth), i]);
|
|
356
666
|
const enterSubmenu = () => {
|
|
357
|
-
const sub = items[active]?.items;
|
|
358
|
-
if (!sub
|
|
667
|
+
const sub = visibleItems(items[active]?.items);
|
|
668
|
+
if (!sub.length) return false;
|
|
359
669
|
setPath([...path, nextSelectable(sub, -1, 1)]);
|
|
360
670
|
return true;
|
|
361
671
|
};
|
|
@@ -393,9 +703,9 @@ function handleMenuKey(
|
|
|
393
703
|
);
|
|
394
704
|
return true;
|
|
395
705
|
}
|
|
396
|
-
case XK_RIGHT:
|
|
706
|
+
case rtl ? XK_LEFT : XK_RIGHT:
|
|
397
707
|
return enterSubmenu();
|
|
398
|
-
case XK_LEFT:
|
|
708
|
+
case rtl ? XK_RIGHT : XK_LEFT:
|
|
399
709
|
if (depth > 0) {
|
|
400
710
|
setPath(path.slice(0, -1));
|
|
401
711
|
return true;
|
|
@@ -420,6 +730,36 @@ function handleMenuKey(
|
|
|
420
730
|
return false;
|
|
421
731
|
}
|
|
422
732
|
|
|
733
|
+
/**
|
|
734
|
+
* A mounted menu honours its own descriptor: the `shortcut` drawn on a row
|
|
735
|
+
* fires that row's `onSelect`, without the app writing the binding a second
|
|
736
|
+
* time in an `onKeyDown` (#351).
|
|
737
|
+
*
|
|
738
|
+
* One array is then the drawn menu, the exported dbusmenu payload, the
|
|
739
|
+
* `aria-keyshortcuts` announcement *and* the binding, so a renamed handler
|
|
740
|
+
* or a newly disabled item cannot make the promise and the behaviour drift.
|
|
741
|
+
*
|
|
742
|
+
* It stays bound when the desktop's panel takes the menu over, and that is
|
|
743
|
+
* not an oversight: the panel draws the rows, but the key is pressed in
|
|
744
|
+
* *this* window and no panel is going to deliver it.
|
|
745
|
+
*
|
|
746
|
+
* `anchorRef` is what the binding hangs off — the bar, or the window when
|
|
747
|
+
* there is no bar to hang off — and decides what a modal `<popup>` takes it
|
|
748
|
+
* away from (`EventManager._runAccelerators`).
|
|
749
|
+
*/
|
|
750
|
+
function useMenuAccelerators(items, select, anchorRef, enabled) {
|
|
751
|
+
useAcceleratorEntry(
|
|
752
|
+
anchorRef,
|
|
753
|
+
(ev) => {
|
|
754
|
+
const item = acceleratedItem(items, ev);
|
|
755
|
+
if (!item) return false;
|
|
756
|
+
select(item);
|
|
757
|
+
return true;
|
|
758
|
+
},
|
|
759
|
+
enabled,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
423
763
|
/**
|
|
424
764
|
* <ContextMenu items>…</ContextMenu> — right-click anywhere in the children
|
|
425
765
|
* to open a menu at the pointer.
|
|
@@ -432,13 +772,16 @@ export function ContextMenu({
|
|
|
432
772
|
items = [],
|
|
433
773
|
children,
|
|
434
774
|
onSelect,
|
|
775
|
+
accelerators = true,
|
|
435
776
|
fontSize = DEFAULT_LABEL_SIZE,
|
|
777
|
+
style,
|
|
436
778
|
...boxProps
|
|
437
779
|
}) {
|
|
438
780
|
const ref = useRef(null);
|
|
439
781
|
const [rect, setRect] = useState(null);
|
|
440
782
|
const [path, setPath] = useState([]);
|
|
441
783
|
const typeAhead = useTypeAhead();
|
|
784
|
+
const rtl = useDirection() === 'rtl';
|
|
442
785
|
|
|
443
786
|
const close = () => {
|
|
444
787
|
setRect(null);
|
|
@@ -450,24 +793,37 @@ export function ContextMenu({
|
|
|
450
793
|
onSelect?.(item);
|
|
451
794
|
};
|
|
452
795
|
|
|
796
|
+
useMenuAccelerators(items, select, ref, accelerators);
|
|
797
|
+
|
|
798
|
+
// the wrapper keeps the focus the right-click gave it, so `onBlur` below
|
|
799
|
+
// never fires when the *window* loses focus — and the menu is holding a
|
|
800
|
+
// pointer grab until something closes it
|
|
801
|
+
useDismissOnWindowBlur(ref, Boolean(rect), close);
|
|
802
|
+
|
|
453
803
|
const openAt = (ev) => {
|
|
454
804
|
const node = ref.current;
|
|
455
805
|
if (!node || !items.length) return;
|
|
456
806
|
const width = menuListWidth(node, items, fontSize);
|
|
457
|
-
const height = menuListHeight(items);
|
|
458
|
-
const
|
|
807
|
+
const height = menuListHeight(items, fontSize);
|
|
808
|
+
const area = anchorArea(node);
|
|
459
809
|
// anchored at the pointer rather than at a widget: clamp by hand, since
|
|
460
|
-
// there is no anchor rect to flip around
|
|
461
|
-
|
|
462
|
-
const
|
|
810
|
+
// there is no anchor rect to flip around. Root coordinates are device;
|
|
811
|
+
// the rect feeds popup props, which are logical like the area.
|
|
812
|
+
const x = (ev.nativeEvent?.rootx ?? ev.x * node.scale) / node.scale;
|
|
813
|
+
const y = (ev.nativeEvent?.rooty ?? ev.y * node.scale) / node.scale;
|
|
463
814
|
setRect({
|
|
464
|
-
x:
|
|
465
|
-
y:
|
|
815
|
+
x: area ? Math.max(area.x, Math.min(x, area.x + area.width - width)) : x,
|
|
816
|
+
y: area
|
|
817
|
+
? Math.max(area.y, Math.min(y, area.y + area.height - height))
|
|
818
|
+
: y,
|
|
466
819
|
width,
|
|
467
820
|
height,
|
|
468
821
|
});
|
|
469
822
|
setPath([-1]);
|
|
470
|
-
|
|
823
|
+
// `'pointer'`, so no ring: this only ever opens from a right-click, and
|
|
824
|
+
// the wrapper is the whole content area — a focus ring around all of it
|
|
825
|
+
// says nothing a keyboard user needs and quite a lot nobody wants.
|
|
826
|
+
node.root?.events?.focus?.(node, 'pointer');
|
|
471
827
|
};
|
|
472
828
|
|
|
473
829
|
return h(
|
|
@@ -476,8 +832,14 @@ export function ContextMenu({
|
|
|
476
832
|
ref,
|
|
477
833
|
focusable: true,
|
|
478
834
|
onMouseDown: (ev) => {
|
|
479
|
-
if (ev.button
|
|
480
|
-
|
|
835
|
+
if (ev.button !== 3 && rect) close();
|
|
836
|
+
},
|
|
837
|
+
onContextMenu: (ev) => {
|
|
838
|
+
// this menu replaces whatever the element under the pointer would
|
|
839
|
+
// have opened on its own — without the preventDefault a
|
|
840
|
+
// <textinput> in here would show its edit menu underneath ours
|
|
841
|
+
ev.preventDefault();
|
|
842
|
+
openAt(ev);
|
|
481
843
|
},
|
|
482
844
|
onKeyDown: (ev) => {
|
|
483
845
|
if (!rect) return;
|
|
@@ -488,10 +850,17 @@ export function ContextMenu({
|
|
|
488
850
|
select,
|
|
489
851
|
close,
|
|
490
852
|
typeAhead,
|
|
853
|
+
rtl,
|
|
491
854
|
});
|
|
855
|
+
// An open menu owns the keyboard, whether or not it had anything to
|
|
856
|
+
// do with this particular key: it is holding a pointer grab, and a
|
|
857
|
+
// shortcut that fired a *second* command out from under the one
|
|
858
|
+
// being pointed at is the shape of bug nobody reproduces.
|
|
859
|
+
ev.preventDefault();
|
|
492
860
|
},
|
|
493
861
|
onBlur: close,
|
|
494
862
|
...boxProps,
|
|
863
|
+
style,
|
|
495
864
|
},
|
|
496
865
|
children,
|
|
497
866
|
rect &&
|
|
@@ -513,92 +882,328 @@ export function ContextMenu({
|
|
|
513
882
|
* <MenuBar menus={[{ label, items }]}/> — a horizontal bar of pull-down
|
|
514
883
|
* menus. Click or Enter opens; with one open, hovering another switches to
|
|
515
884
|
* it and Left/Right walk the bar; the usual menu keys work inside.
|
|
885
|
+
*
|
|
886
|
+
* **On a desktop with a global menu, this renders nothing** and the panel
|
|
887
|
+
* shows the menu instead — automatically, with no configuration, because the
|
|
888
|
+
* `menus` array is a plain data prop and serialises to `com.canonical.dbusmenu`
|
|
889
|
+
* unchanged (`src/globalmenu.js`). `globalMenu={false}` keeps the bar in the
|
|
890
|
+
* window on a desktop that would otherwise take it.
|
|
516
891
|
*/
|
|
517
892
|
export function MenuBar({
|
|
518
893
|
menus = [],
|
|
519
894
|
onSelect,
|
|
895
|
+
globalMenu = true,
|
|
896
|
+
onGlobalMenuChange,
|
|
897
|
+
accelerators = true,
|
|
520
898
|
fontSize = DEFAULT_LABEL_SIZE,
|
|
899
|
+
style,
|
|
521
900
|
...boxProps
|
|
522
901
|
}) {
|
|
523
902
|
const theme = useTheme();
|
|
903
|
+
const rtl = useDirection() === 'rtl';
|
|
524
904
|
const [openIndex, setOpenIndex] = useState(-1);
|
|
525
905
|
const [rect, setRect] = useState(null);
|
|
526
906
|
const [path, setPath] = useState([]);
|
|
527
907
|
const refs = useRef([]);
|
|
528
908
|
const typeAhead = useTypeAhead();
|
|
529
909
|
|
|
530
|
-
|
|
910
|
+
// Which menu is open, readable from a handler that is running *inside*
|
|
911
|
+
// the call that changed it. Switching menus focuses the item taking over,
|
|
912
|
+
// which blurs the one handing off, and that blur handler would otherwise
|
|
913
|
+
// see the `openIndex` of the render it was created in — still itself —
|
|
914
|
+
// and close the menu the switch had just opened.
|
|
915
|
+
const openRef = useRef(-1);
|
|
916
|
+
|
|
917
|
+
// Drawn from the visible menus, exported from all of them: dbusmenu carries
|
|
918
|
+
// `visible` as a property, so hiding one there is a patch the panel applies
|
|
919
|
+
// rather than a structural change that renumbers everything after it.
|
|
920
|
+
const bar = useMemo(() => visibleItems(menus), [menus]);
|
|
921
|
+
const delegated = useGlobalMenu(menus, { onSelect, enabled: globalMenu });
|
|
922
|
+
|
|
923
|
+
// What the bar turned out to be, which is the one thing here that cannot
|
|
924
|
+
// be worked out in a render: layout runs on the frame clock, after the
|
|
925
|
+
// commit that mounted the box, so `onViewport` is the supported way to
|
|
926
|
+
// hear about a size (docs/react-features.md) — and it is what makes the
|
|
927
|
+
// box a scroll container, which is also what clips a bar mid-cut.
|
|
928
|
+
//
|
|
929
|
+
// `0` means "not measured yet", and an unmeasured bar shows everything:
|
|
930
|
+
// that is what every bar did before this, and it is right for every bar
|
|
931
|
+
// that fits. The window narrower than its titles is one frame late, not
|
|
932
|
+
// wrong.
|
|
933
|
+
const barRef = useRef(null);
|
|
934
|
+
const owner = useTopLevelWindow();
|
|
935
|
+
const [barWidth, setBarWidth] = useState(0);
|
|
936
|
+
const [fits, setFits] = useState(-1);
|
|
937
|
+
useEffect(() => {
|
|
938
|
+
if (!barWidth || !barRef.current) return;
|
|
939
|
+
setFits(barCut(barRef.current, bar, fontSize, barWidth));
|
|
940
|
+
}, [bar, fontSize, barWidth]);
|
|
941
|
+
|
|
942
|
+
// The bar as it is actually drawn: the titles that fit, then the chevron
|
|
943
|
+
// standing for the rest. Everything downstream — the open index, the
|
|
944
|
+
// anchor, Left/Right, the popup — reads this rather than `menus`, which
|
|
945
|
+
// is the whole of what keeps the keyboard on what the eye can see.
|
|
946
|
+
const entries = useMemo(() => {
|
|
947
|
+
if (fits < 0 || fits >= bar.length) return bar;
|
|
948
|
+
return [
|
|
949
|
+
...bar.slice(0, fits),
|
|
950
|
+
{ [OVERFLOW]: true, items: bar.slice(fits) },
|
|
951
|
+
];
|
|
952
|
+
}, [bar, fits]);
|
|
953
|
+
|
|
954
|
+
// The one thing about this an app cannot find out for itself: calling
|
|
955
|
+
// `useGlobalMenu` a second time would export the menu twice, on two paths,
|
|
956
|
+
// with the second registration displacing the first. So the answer is
|
|
957
|
+
// reported rather than left to be re-derived. It is worth having — a window
|
|
958
|
+
// sized to its content is a menu bar shorter when the panel takes it, and
|
|
959
|
+
// copy that says "the bar above" is wrong the moment there is not one.
|
|
960
|
+
const notifyDelegation = useRef(onGlobalMenuChange);
|
|
961
|
+
notifyDelegation.current = onGlobalMenuChange;
|
|
962
|
+
useEffect(() => {
|
|
963
|
+
notifyDelegation.current?.(delegated);
|
|
964
|
+
}, [delegated]);
|
|
965
|
+
|
|
966
|
+
const items = openIndex >= 0 ? (entries[openIndex]?.items ?? []) : [];
|
|
531
967
|
|
|
532
968
|
const close = () => {
|
|
969
|
+
openRef.current = -1;
|
|
533
970
|
setOpenIndex(-1);
|
|
534
971
|
setRect(null);
|
|
535
972
|
setPath([]);
|
|
536
973
|
};
|
|
537
974
|
|
|
538
|
-
|
|
975
|
+
// A panel taking the menu over while one is pulled down would otherwise
|
|
976
|
+
// leave `openIndex` set, so the bar would come back open if the panel later
|
|
977
|
+
// went away — and the popup holds a pointer grab until something closes it.
|
|
978
|
+
useEffect(() => {
|
|
979
|
+
if (delegated) close();
|
|
980
|
+
}, [delegated]);
|
|
981
|
+
|
|
982
|
+
// Narrowing the window while a menu is down can take the item it hangs
|
|
983
|
+
// off the bar. Anchor tracking follows a trigger that *moves*; one that
|
|
984
|
+
// stopped existing leaves the popup where it was, hanging under nothing
|
|
985
|
+
// and still holding its grab, so it is shut here instead. Reopening the
|
|
986
|
+
// same menu from the chevron is one click, and guessing which of the two
|
|
987
|
+
// the user meant is worse than either.
|
|
988
|
+
useEffect(() => {
|
|
989
|
+
if (openIndex >= entries.length) close();
|
|
990
|
+
}, [entries.length, openIndex]);
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* `reason` is the gesture that opened the menu, and it decides the ring.
|
|
994
|
+
* A menu opened with the pointer already tells you where you are — the
|
|
995
|
+
* menu itself is hanging off the item — so a ring on top of that is the
|
|
996
|
+
* noise `:focus-visible` exists to remove. Walking the bar with the arrow
|
|
997
|
+
* keys is the opposite case: the ring is the only thing saying which item
|
|
998
|
+
* the keyboard is on.
|
|
999
|
+
*/
|
|
1000
|
+
const openMenu = (index, reason = 'key') => {
|
|
539
1001
|
const node = refs.current[index];
|
|
540
|
-
const menu =
|
|
541
|
-
if (!node || !menu
|
|
1002
|
+
const menu = entries[index];
|
|
1003
|
+
if (!node || !hasSubmenu(menu)) return;
|
|
542
1004
|
const width = menuListWidth(node, menu.items, fontSize);
|
|
543
|
-
const height = menuListHeight(menu.items);
|
|
1005
|
+
const height = menuListHeight(menu.items, fontSize);
|
|
544
1006
|
const next = anchorRect(node, { placement: 'bottom', width, height });
|
|
545
1007
|
if (!next) return;
|
|
1008
|
+
openRef.current = index;
|
|
546
1009
|
setRect(next);
|
|
547
1010
|
setOpenIndex(index);
|
|
548
1011
|
setPath([-1]);
|
|
1012
|
+
// Take focus **deliberately**, the way `ContextMenu` does, and for the
|
|
1013
|
+
// same reason: the popup is override-redirect and never gets the X
|
|
1014
|
+
// focus, so something in the owner window has to hold it or keys go
|
|
1015
|
+
// nowhere the menu can hear them.
|
|
1016
|
+
//
|
|
1017
|
+
// Both ways out of an open menu hang off this one thread. Escape is a
|
|
1018
|
+
// key, so it arrives at the focused node; clicking away closes through
|
|
1019
|
+
// `onBlur`, which only fires on a node that was focused to begin with.
|
|
1020
|
+
// Leaving that to whatever the press happened to focus made both
|
|
1021
|
+
// depend on the gesture that opened the menu — and a menu you cannot
|
|
1022
|
+
// dismiss is worse than one that never opened, because it is holding a
|
|
1023
|
+
// pointer grab while you try.
|
|
1024
|
+
node.root?.events?.focus?.(node, reason);
|
|
549
1025
|
};
|
|
550
1026
|
|
|
1027
|
+
// keeps the pulled-down menu under its bar item for as long as it is
|
|
1028
|
+
// open: a scrolled ancestor, the item's own layout moving it, or the
|
|
1029
|
+
// owner window being nudged by the window manager or a script would
|
|
1030
|
+
// otherwise leave it stranded. `openRef` (not `openIndex`) both because
|
|
1031
|
+
// it is already the live index a handler mid-call reads, and because a
|
|
1032
|
+
// stable ref keeps `activeTriggerRef`'s identity fixed across renders. If
|
|
1033
|
+
// the bar item itself scrolls out of view, the menu closes rather than
|
|
1034
|
+
// following it there.
|
|
1035
|
+
const activeTriggerRef = useMemo(
|
|
1036
|
+
() => ({
|
|
1037
|
+
get current() {
|
|
1038
|
+
return refs.current[openRef.current] ?? null;
|
|
1039
|
+
},
|
|
1040
|
+
}),
|
|
1041
|
+
[],
|
|
1042
|
+
);
|
|
1043
|
+
useAnchorTracking(
|
|
1044
|
+
activeTriggerRef,
|
|
1045
|
+
openIndex >= 0,
|
|
1046
|
+
() => {
|
|
1047
|
+
const node = refs.current[openRef.current];
|
|
1048
|
+
const menu = entries[openRef.current];
|
|
1049
|
+
if (!node || !hasSubmenu(menu)) return null;
|
|
1050
|
+
return {
|
|
1051
|
+
placement: 'bottom',
|
|
1052
|
+
width: menuListWidth(node, menu.items, fontSize),
|
|
1053
|
+
height: menuListHeight(menu.items, fontSize),
|
|
1054
|
+
};
|
|
1055
|
+
},
|
|
1056
|
+
setRect,
|
|
1057
|
+
close,
|
|
1058
|
+
);
|
|
1059
|
+
// and shut it when the whole window loses focus. The bar item keeps the
|
|
1060
|
+
// focus it took, so its own `onBlur` never fires for this — and a menu
|
|
1061
|
+
// left open over an application the user has switched away from is still
|
|
1062
|
+
// holding the pointer grab it opened with.
|
|
1063
|
+
useDismissOnWindowBlur(activeTriggerRef, openIndex >= 0, close);
|
|
1064
|
+
|
|
551
1065
|
const select = (item) => {
|
|
552
1066
|
close();
|
|
553
1067
|
item.onSelect?.(item);
|
|
554
1068
|
onSelect?.(item);
|
|
555
1069
|
};
|
|
556
1070
|
|
|
1071
|
+
// The bar when there is one, the window when the panel has taken it: a
|
|
1072
|
+
// delegated `MenuBar` draws nothing at all, and a binding anchored to a
|
|
1073
|
+
// node that no longer exists is a binding that stopped working exactly
|
|
1074
|
+
// when it became the only way to reach the command.
|
|
1075
|
+
const acceleratorAnchor = useMemo(
|
|
1076
|
+
() => ({
|
|
1077
|
+
get current() {
|
|
1078
|
+
return barRef.current ?? owner.current;
|
|
1079
|
+
},
|
|
1080
|
+
}),
|
|
1081
|
+
[owner],
|
|
1082
|
+
);
|
|
1083
|
+
useMenuAccelerators(menus, select, acceleratorAnchor, accelerators);
|
|
1084
|
+
|
|
557
1085
|
const moveMenu = (dir) => {
|
|
558
|
-
if (!
|
|
559
|
-
const n =
|
|
1086
|
+
if (!entries.length) return;
|
|
1087
|
+
const n = entries.length;
|
|
560
1088
|
openMenu((openIndex + dir + n) % n);
|
|
561
1089
|
};
|
|
562
1090
|
|
|
1091
|
+
// The desktop is drawing this menu, so we must not. After every hook, and
|
|
1092
|
+
// only on evidence — `delegated` is false until the registrar has answered
|
|
1093
|
+
// `RegisterWindow`, so a machine with no panel, no bus, or a panel that
|
|
1094
|
+
// refused the registration all keep the bar where the app put it.
|
|
1095
|
+
if (delegated) return null;
|
|
1096
|
+
|
|
563
1097
|
return h(
|
|
564
1098
|
'box',
|
|
565
1099
|
{
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1100
|
+
theme,
|
|
1101
|
+
role: 'menubar',
|
|
1102
|
+
ref: barRef,
|
|
1103
|
+
scrollbar: false,
|
|
569
1104
|
...boxProps,
|
|
1105
|
+
// Layout is the only thing that knows how wide the bar came out, and
|
|
1106
|
+
// it says so here. Width alone: `contentWidth` changes as the cut is
|
|
1107
|
+
// applied, and storing that would be reacting to our own answer.
|
|
1108
|
+
//
|
|
1109
|
+
// After the spread and chained rather than before it and overridable:
|
|
1110
|
+
// an application is welcome to watch its own menu bar resize, and a
|
|
1111
|
+
// handler of its own taking this one's place would quietly leave the
|
|
1112
|
+
// bar unable to measure itself — a prop that turns a feature off by
|
|
1113
|
+
// accident.
|
|
1114
|
+
onViewport: (v) => {
|
|
1115
|
+
setBarWidth(v.width);
|
|
1116
|
+
boxProps.onViewport?.(v);
|
|
1117
|
+
},
|
|
1118
|
+
style: [
|
|
1119
|
+
{
|
|
1120
|
+
flexDirection: 'row',
|
|
1121
|
+
alignItems: 'center',
|
|
1122
|
+
backgroundColor: theme.surfaceHover,
|
|
1123
|
+
paddingLeft: BAR_INSET - BAR_GAP,
|
|
1124
|
+
paddingRight: BAR_INSET - BAR_GAP,
|
|
1125
|
+
// `scroll` is what makes a box report its viewport, and the clip
|
|
1126
|
+
// that comes with it is wanted in its own right: it is what holds
|
|
1127
|
+
// the one frame before the first measurement — and any bar whose
|
|
1128
|
+
// own titles cannot be cut down far enough — inside the window
|
|
1129
|
+
// instead of letting it run off the edge. Nothing is ever left to
|
|
1130
|
+
// scroll to once the cut lands, so the bar answers no keys and
|
|
1131
|
+
// takes no wheel (docs/elements.md).
|
|
1132
|
+
overflow: 'scroll',
|
|
1133
|
+
// A scroll container brings `minHeight: 0` with it; a menu bar is
|
|
1134
|
+
// not a thing that gives up height when the window is short, so it
|
|
1135
|
+
// opts out of shrinking altogether.
|
|
1136
|
+
flexShrink: 0,
|
|
1137
|
+
},
|
|
1138
|
+
style,
|
|
1139
|
+
],
|
|
570
1140
|
},
|
|
571
|
-
|
|
572
|
-
|
|
1141
|
+
entries.map((menu, index) => {
|
|
1142
|
+
// The bar is the first level of the same trail: while its menu is open
|
|
1143
|
+
// with nothing chosen in it the item is the selection, and the moment a
|
|
1144
|
+
// row down there takes over it goes quiet — the same handover, drawn
|
|
1145
|
+
// the same way, one level up (`rowState`).
|
|
1146
|
+
const barState = rowState(index, openIndex, (path[0] ?? -1) >= 0);
|
|
1147
|
+
const overflow = isOverflow(menu);
|
|
1148
|
+
return h(
|
|
573
1149
|
'box',
|
|
574
1150
|
{
|
|
575
|
-
key: menu.label,
|
|
1151
|
+
key: overflow ? OVERFLOW_KEY : menu.label,
|
|
1152
|
+
role: 'menuitem',
|
|
1153
|
+
// The glyph is decoration — `<Icon>` is `aria-hidden` by default —
|
|
1154
|
+
// so the chevron is the one bar item with nothing to read out, and
|
|
1155
|
+
// it says how many titles are behind it because "more" without a
|
|
1156
|
+
// number is the question rather than the answer. The hidden menus
|
|
1157
|
+
// themselves are simply not in the tree: AT-SPI has no notion of
|
|
1158
|
+
// "laid out past the edge", and the fix for reaching them is the
|
|
1159
|
+
// same for a screen reader as for a pointer — this item.
|
|
1160
|
+
'aria-label': overflow
|
|
1161
|
+
? `More menus (${menu.items.length})`
|
|
1162
|
+
: undefined,
|
|
1163
|
+
'aria-haspopup': 'menu',
|
|
1164
|
+
'aria-expanded': openIndex === index,
|
|
576
1165
|
ref: (node) => {
|
|
577
1166
|
refs.current[index] = node;
|
|
578
1167
|
},
|
|
579
1168
|
focusable: true,
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
backgroundColor:
|
|
586
|
-
openIndex === index ? theme.hoverBackground : undefined,
|
|
587
|
-
onClick: () => (openIndex === index ? close() : openMenu(index)),
|
|
588
|
-
// with a menu already open, hovering the bar switches menus —
|
|
589
|
-
// standard pull-down behaviour
|
|
1169
|
+
// on the press, as `Select` opens: a pull-down that waits for the
|
|
1170
|
+
// button to come back up spends the whole of a held click saying
|
|
1171
|
+
// nothing, and a menu is there to be read
|
|
1172
|
+
onMouseDown: () =>
|
|
1173
|
+
openIndex === index ? close() : openMenu(index, 'pointer'),
|
|
590
1174
|
onMouseEnter: () => {
|
|
591
|
-
if (openIndex >= 0 && openIndex !== index)
|
|
1175
|
+
if (openIndex >= 0 && openIndex !== index) {
|
|
1176
|
+
openMenu(index, 'pointer');
|
|
1177
|
+
}
|
|
592
1178
|
},
|
|
1179
|
+
// read live: by the time a hand-off blurs this item, the item
|
|
1180
|
+
// taking over has already claimed the bar
|
|
593
1181
|
onBlur: () => {
|
|
594
|
-
if (
|
|
1182
|
+
if (openRef.current === index) close();
|
|
595
1183
|
},
|
|
596
1184
|
onKeyDown: (ev) => {
|
|
1185
|
+
// An open menu owns the keyboard for as long as it is up,
|
|
1186
|
+
// whether or not it had anything to do with this key: it is
|
|
1187
|
+
// holding a pointer grab, and an accelerator that fired a
|
|
1188
|
+
// *second* command out from under the one being pointed at is
|
|
1189
|
+
// the shape of bug nobody reproduces. Said here rather than in
|
|
1190
|
+
// `handleMenuKey`, which answers for a closed bar too.
|
|
1191
|
+
if (openIndex >= 0) ev.preventDefault();
|
|
597
1192
|
if (openIndex !== index) {
|
|
1193
|
+
// Escape shuts an open menu from any item on the bar, not
|
|
1194
|
+
// only the one it belongs to. A menu holds a pointer grab, so
|
|
1195
|
+
// the key that gets rid of it is the one thing that must not
|
|
1196
|
+
// depend on which node the keystroke happened to reach. The
|
|
1197
|
+
// item that *owns* the menu falls through to `handleMenuKey`
|
|
1198
|
+
// instead, where Escape leaves one submenu level at a time.
|
|
1199
|
+
if (ev.keysym === XK_ESCAPE && openIndex >= 0) {
|
|
1200
|
+
close();
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
598
1203
|
if (ev.codepoint === 32 || ev.keysym === XK_RETURN) {
|
|
599
1204
|
openMenu(index);
|
|
600
|
-
} else if (ev.keysym === XK_LEFT) moveMenu(-1);
|
|
601
|
-
else if (ev.keysym === XK_RIGHT) moveMenu(1);
|
|
1205
|
+
} else if (ev.keysym === XK_LEFT) moveMenu(rtl ? 1 : -1);
|
|
1206
|
+
else if (ev.keysym === XK_RIGHT) moveMenu(rtl ? -1 : 1);
|
|
602
1207
|
return;
|
|
603
1208
|
}
|
|
604
1209
|
// the menu gets first refusal: Left leaves a submenu and Right
|
|
@@ -611,22 +1216,81 @@ export function MenuBar({
|
|
|
611
1216
|
select,
|
|
612
1217
|
close,
|
|
613
1218
|
typeAhead,
|
|
1219
|
+
rtl,
|
|
614
1220
|
});
|
|
615
1221
|
if (consumed) return;
|
|
616
|
-
|
|
617
|
-
|
|
1222
|
+
// the bar runs the way the titles do, so Left is the next title
|
|
1223
|
+
// along in a mirrored bar
|
|
1224
|
+
if (ev.keysym === XK_LEFT) moveMenu(rtl ? 1 : -1);
|
|
1225
|
+
else if (ev.keysym === XK_RIGHT) moveMenu(rtl ? -1 : 1);
|
|
618
1226
|
},
|
|
1227
|
+
style: [
|
|
1228
|
+
{
|
|
1229
|
+
cursor: 'pointer',
|
|
1230
|
+
paddingLeft: BAR_ITEM_PAD_X,
|
|
1231
|
+
paddingRight: BAR_ITEM_PAD_X,
|
|
1232
|
+
// The same pill the rows inside the menu wear, at the same
|
|
1233
|
+
// radius: the bar item and the first row of the menu it opens
|
|
1234
|
+
// are one gesture, and a square title over rounded rows reads
|
|
1235
|
+
// as two widgets that have not met.
|
|
1236
|
+
//
|
|
1237
|
+
// The margin is what a radius needs to be seen — a rounded
|
|
1238
|
+
// rect flush against the strip's own edges reads as a cut
|
|
1239
|
+
// corner rather than a pill. It sits *outside* the padding, so
|
|
1240
|
+
// the bar is a menu row plus its two insets tall: the item and
|
|
1241
|
+
// the row below it are then the same pill in the same size,
|
|
1242
|
+
// which is the whole point of giving the bar one.
|
|
1243
|
+
marginTop: BAR_INSET,
|
|
1244
|
+
marginBottom: BAR_INSET,
|
|
1245
|
+
marginLeft: BAR_GAP,
|
|
1246
|
+
marginRight: BAR_GAP,
|
|
1247
|
+
paddingTop: MENU_ITEM_PAD,
|
|
1248
|
+
paddingBottom: MENU_ITEM_PAD,
|
|
1249
|
+
borderRadius: rowRadius(theme, MENU_BORDER, MENU_PAD),
|
|
1250
|
+
backgroundColor:
|
|
1251
|
+
barState === 'active'
|
|
1252
|
+
? theme.hoverBackground
|
|
1253
|
+
: barState === 'path'
|
|
1254
|
+
? theme.surfaceActive
|
|
1255
|
+
: undefined,
|
|
1256
|
+
// No ring while this item's menu is up. Walking the bar with
|
|
1257
|
+
// the arrow keys opens each menu as it arrives, so the item is
|
|
1258
|
+
// already inverted with a menu hanging off it — a ring on top
|
|
1259
|
+
// of that is saying a third time what two things already say,
|
|
1260
|
+
// and it is the least specific of the three.
|
|
1261
|
+
//
|
|
1262
|
+
// Only while it is up, though: an item that has been tabbed to
|
|
1263
|
+
// and *not* opened has nothing else to show for it, and a
|
|
1264
|
+
// keyboard user who cannot see where they are is exactly the
|
|
1265
|
+
// reader the ring exists for.
|
|
1266
|
+
outlineWidth: openIndex === index ? 0 : undefined,
|
|
1267
|
+
// said once for whichever of the two the title turns out to be
|
|
1268
|
+
color: barState === 'active' ? theme.hoverText : theme.text,
|
|
1269
|
+
},
|
|
1270
|
+
// Only while this menu is shut, as in `Select`: an open one is
|
|
1271
|
+
// already showing the answer, and a state block would outrank
|
|
1272
|
+
// the base colour that says so. The menu opens on the release,
|
|
1273
|
+
// so `:active` is the whole of the answer to a held press.
|
|
1274
|
+
openIndex !== index && {
|
|
1275
|
+
':hover': { backgroundColor: theme.surface },
|
|
1276
|
+
':active': { backgroundColor: theme.surfaceActive },
|
|
1277
|
+
},
|
|
1278
|
+
],
|
|
619
1279
|
},
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
1280
|
+
overflow
|
|
1281
|
+
? h(Icon, {
|
|
1282
|
+
// The set's own overflow mark, rather than the `»` Qt and
|
|
1283
|
+
// Firefox draw or the `⋯` a modern toolbar does: both are text
|
|
1284
|
+
// glyphs, and a text glyph is only as good as the font it
|
|
1285
|
+
// lands in — tofu on a machine without it, which is the exact
|
|
1286
|
+
// warning docs/components.md gives applications about string
|
|
1287
|
+
// `icon`s. One drawing, every font, both colour schemes.
|
|
1288
|
+
name: 'moreVertical',
|
|
1289
|
+
size: iconSize(fontSize),
|
|
1290
|
+
})
|
|
1291
|
+
: h('text', { style: [capTrim, { fontSize }] }, menu.label),
|
|
1292
|
+
);
|
|
1293
|
+
}),
|
|
630
1294
|
openIndex >= 0 &&
|
|
631
1295
|
rect &&
|
|
632
1296
|
path.length > 0 &&
|