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.
Files changed (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
@@ -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 { useTheme } from './theme.js';
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
- const MENU_ITEM_HEIGHT = 26;
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 body = items.reduce(
52
- (sum, item) =>
53
- sum + (item.separator ? MENU_SEPARATOR_HEIGHT : MENU_ITEM_HEIGHT),
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 * 2 + 2;
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.separator) continue;
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 shortcut = item.shortcut
68
- ? measureLabel(node, item.shortcut, { size: fontSize }).width +
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) + MENU_GUTTER + MENU_PAD * 2 + 12,
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
- active,
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
- if (item.separator) {
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
- { height: MENU_SEPARATOR_HEIGHT, justifyContent: 'center' },
104
- h('box', { height: 1, backgroundColor: theme.border }),
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.disabled;
108
- const hasSubmenu = item.items?.length > 0;
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 - 8, alignItems: 'center' },
127
- item.checked &&
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('box', { flexGrow: 1 }),
143
- item.shortcut &&
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
- color: dim ? theme.dim : active ? theme.hoverText : theme.dim,
148
- fontSize,
414
+ style: [capTrim, { fontSize }, !active && { color: theme.textMuted }],
149
415
  },
150
- item.shortcut,
416
+ accelerator,
151
417
  ),
152
- hasSubmenu &&
153
- h('text', {
154
- color: dim ? theme.dim : active ? theme.hoverText : theme.dim,
155
- fontSize,
156
- children: '\u25b8',
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
- /** Items at `depth`, walking `path` down through nested `items`. */
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?.length > 0;
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
- placement: 'right',
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
- offset: 0,
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]?.items?.length ? [...base, -1] : base);
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.items?.length) {
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
- flexGrow: 1,
305
- flexShrink: 1,
306
- padding: MENU_PAD,
307
- borderWidth: 1,
308
- borderColor: theme.border,
309
- backgroundColor: theme.background,
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.separator ? `sep-${index}` : (item.key ?? item.label),
620
+ key: isSeparator(item) ? `sep-${index}` : (item.key ?? item.label),
314
621
  item,
315
- active: index === active,
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?.length) return false;
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 screen = screenOf(node);
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
- const x = ev.nativeEvent?.rootx ?? ev.x;
462
- const y = ev.nativeEvent?.rooty ?? ev.y;
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: screen ? Math.max(0, Math.min(x, screen.pixel_width - width)) : x,
465
- y: screen ? Math.max(0, Math.min(y, screen.pixel_height - height)) : 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
- node.root?.events?.focus?.(node);
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 === 3) openAt(ev);
480
- else if (rect) close();
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
- const items = openIndex >= 0 ? (menus[openIndex]?.items ?? []) : [];
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
- const openMenu = (index) => {
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 = menus[index];
541
- if (!node || !menu?.items?.length) return;
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 (!menus.length) return;
559
- const n = menus.length;
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
- flexDirection: 'row',
567
- alignItems: 'center',
568
- backgroundColor: theme.surfaceHover,
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
- menus.map((menu, index) =>
572
- h(
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
- cursor: 'pointer',
581
- paddingLeft: 10,
582
- paddingRight: 10,
583
- paddingTop: 6,
584
- paddingBottom: 6,
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) openMenu(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 (openIndex === index) close();
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
- if (ev.keysym === XK_LEFT) moveMenu(-1);
617
- else if (ev.keysym === XK_RIGHT) moveMenu(1);
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
- h(
621
- 'text',
622
- {
623
- color: openIndex === index ? theme.hoverText : theme.text,
624
- fontSize,
625
- },
626
- menu.label,
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 &&