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
@@ -3,6 +3,7 @@
3
3
  // build-step-free for consumers.
4
4
 
5
5
  import React, { useContext, useEffect, useMemo, useRef } from 'react';
6
+ import { changeEvent } from './change.js';
6
7
  import { labelContent, useControl, useTheme } from './theme.js';
7
8
  import { XK_DOWN, XK_LEFT, XK_RIGHT, XK_UP } from './keys.js';
8
9
 
@@ -11,16 +12,27 @@ const h = React.createElement;
11
12
  const RadioGroupContext = React.createContext(null);
12
13
 
13
14
  /**
14
- * <RadioGroup value onChange>…<Radio value=…>label</Radio>…</RadioGroup>
15
- * exclusive choice. Arrow keys move the selection through the group in
15
+ * <RadioGroup value onChange name>…<Radio value=…>label</Radio>…</RadioGroup>
16
+ * exclusive choice. Arrow keys move the selection through the group in
16
17
  * mount order (wrapping); click or Space selects the focused radio.
18
+ * `name` lives on the group, not the radios, the way it does in HTML: it is
19
+ * the group that is one form field. `onChange(ev)`, with the chosen value
20
+ * on `ev.value`.
17
21
  */
18
- export function RadioGroup({ value, onChange, children, ...boxProps }) {
22
+ export function RadioGroup({
23
+ value,
24
+ onChange,
25
+ name,
26
+ children,
27
+ style,
28
+ ...boxProps
29
+ }) {
19
30
  const order = useRef([]).current;
20
- const ctx = useMemo(
21
- () => ({
31
+ const ctx = useMemo(() => {
32
+ const emit = (next) => onChange?.(changeEvent('radio', name, next));
33
+ return {
22
34
  value,
23
- onChange,
35
+ emit,
24
36
  register: (v) => {
25
37
  order.push(v);
26
38
  return () => order.splice(order.indexOf(v), 1);
@@ -29,14 +41,13 @@ export function RadioGroup({ value, onChange, children, ...boxProps }) {
29
41
  if (order.length === 0) return;
30
42
  const i = order.indexOf(value);
31
43
  const next = order[(i + delta + order.length) % order.length];
32
- if (next !== value) onChange?.(next);
44
+ if (next !== value) emit(next);
33
45
  },
34
- }),
35
- [value, onChange, order],
36
- );
46
+ };
47
+ }, [value, onChange, name, order]);
37
48
  return h(
38
49
  'box',
39
- { gap: 6, ...boxProps },
50
+ { role: 'radiogroup', ...boxProps, style: [{ gap: 6 }, style] },
40
51
  h(RadioGroupContext.Provider, { value: ctx }, children),
41
52
  );
42
53
  }
@@ -49,47 +60,79 @@ export function Radio({ value, children, label, disabled = false }) {
49
60
  }
50
61
  useEffect(() => group.register(value), [group, value]);
51
62
  const selected = group.value === value;
52
- const { focused, props } = useControl(disabled, () => {
53
- if (!selected) group.onChange?.(value);
63
+ const {
64
+ hover,
65
+ focused,
66
+ pressed,
67
+ props,
68
+ style: controlStyle,
69
+ } = useControl(disabled, () => {
70
+ if (!selected) group.emit(value);
54
71
  });
55
- const onKeyDown = props.onKeyDown;
56
- if (onKeyDown) {
72
+ // The arrows are the group's, and they are all this handler is for: Space
73
+ // and Enter are the click core makes of them on anything with an `onClick`
74
+ // (issue #329), which for a radio is `useControl`'s. A disabled radio takes
75
+ // no keys and gets no handler — it has no `onClick` either.
76
+ if (!disabled) {
57
77
  props.onKeyDown = (ev) => {
58
78
  if (ev.keysym === XK_DOWN || ev.keysym === XK_RIGHT) group.move(1);
59
79
  else if (ev.keysym === XK_UP || ev.keysym === XK_LEFT) group.move(-1);
60
- else onKeyDown(ev);
61
80
  };
62
81
  }
63
- const fill = disabled ? theme.dim : theme.accent;
82
+ // The dot only appears on the release, so the well answers the press
83
+ // itself — see Checkbox, both for the three-look treatment and for why
84
+ // this is React state rather than an `:active` block.
85
+ const fill = disabled
86
+ ? theme.textMuted
87
+ : pressed
88
+ ? theme.accentActive
89
+ : hover
90
+ ? theme.accentHover
91
+ : theme.accent;
64
92
  return h(
65
93
  'box',
66
- { flexDirection: 'row', alignItems: 'center', gap: 8, ...props },
94
+ {
95
+ theme,
96
+ role: 'radio',
97
+ 'aria-checked': selected,
98
+ ...props,
99
+ style: [
100
+ controlStyle,
101
+ { flexDirection: 'row', alignItems: 'center', gap: 8 },
102
+ ],
103
+ },
67
104
  h(
68
105
  'box',
69
106
  {
70
- width: 16,
71
- height: 16,
72
- borderRadius: 8,
73
- borderWidth: 1,
74
- borderColor: selected
75
- ? fill
76
- : focused
77
- ? theme.borderActive
78
- : theme.border,
79
- backgroundColor: theme.background,
80
- alignItems: 'center',
81
- justifyContent: 'center',
107
+ style: {
108
+ width: 16,
109
+ height: 16,
110
+ borderRadius: 8,
111
+ borderWidth: theme.borderWidth,
112
+ borderColor: selected
113
+ ? fill
114
+ : pressed || hover
115
+ ? theme.textMuted
116
+ : focused
117
+ ? theme.borderFocus
118
+ : theme.border,
119
+ backgroundColor: pressed ? theme.surfaceActive : theme.surface,
120
+ alignItems: 'center',
121
+ justifyContent: 'center',
122
+ },
82
123
  },
83
124
  selected &&
84
125
  h('box', {
85
- width: 8,
86
- height: 8,
87
- borderRadius: 4,
88
- backgroundColor: fill,
126
+ style: {
127
+ width: 8,
128
+ height: 8,
129
+ borderRadius: 4,
130
+ backgroundColor: fill,
131
+ },
89
132
  }),
90
133
  ),
91
134
  labelContent(children ?? label, {
92
- color: disabled ? theme.dim : theme.text,
135
+ color: disabled ? theme.textMuted : theme.text,
93
136
  }),
94
137
  );
95
138
  }
@@ -3,8 +3,16 @@
3
3
  // build-step-free for consumers.
4
4
 
5
5
  import React, { useEffect, useRef, useState } from 'react';
6
- import { useTheme } from './theme.js';
7
- import { useAnchor } from './anchor.js';
6
+ import { capBand, capTrim, rowRadius, useTheme } from './theme.js';
7
+ import { Icon } from './Icon.js';
8
+ import { changeEvent } from './change.js';
9
+ import {
10
+ anchorArea,
11
+ measureLabel,
12
+ useAnchor,
13
+ useAnchorTracking,
14
+ useDismissOnWindowBlur,
15
+ } from './anchor.js';
8
16
  import { typeAheadChar, useTypeAhead } from './typeahead.js';
9
17
  import {
10
18
  XK_DOWN,
@@ -19,37 +27,131 @@ import {
19
27
 
20
28
  const h = React.createElement;
21
29
 
22
- const ITEM_HEIGHT = 28;
23
-
24
30
  const MAX_MENU_HEIGHT = 220;
25
31
 
32
+ // The menu's chrome, named rather than inline because the width calculation
33
+ // and the layout have to agree: a measurement that drifts from the padding it
34
+ // is measuring for clips the very labels it exists to fit.
35
+ const ITEM_PAD = 10;
36
+ const ITEM_PAD_LEFT = ITEM_PAD;
37
+ const ITEM_PAD_RIGHT = ITEM_PAD;
38
+ // A row is its label with even space all round — the same number left and
39
+ // right as above and below, measured to the letters rather than to the
40
+ // font's line box (`capTrim`). The menus derive their rows the same way.
41
+ //
42
+ // A function of the palette's text size and not of a constant: the labels in
43
+ // these rows are unstyled `<text>`, which *inherits* that size, so a row
44
+ // sized against 14 under a theme that set 18 is a row its own label does not
45
+ // fit in.
46
+ const itemHeight = (fontSize) => capBand(fontSize) + ITEM_PAD * 2;
47
+ // The scrolling pane's own padding, and so the inset between the edge
48
+ // and an option — which is what makes the highlight read as a pill on the
49
+ // menu rather than a band across it, and what the pill's own corner radius
50
+ // is derived from so the two curves share a centre. The same number the
51
+ // menus use (`Menu.js`): a dropdown is the same kind of surface and there is
52
+ // no reading in which it wants a different one.
53
+ const MENU_PAD = 4;
54
+ // A hairline, not `theme.borderWidth`: this border gives the sheet an edge
55
+ // where it meets the desktop behind it, and a theme that draws 2px borders
56
+ // on its *controls* does not mean a 2px outline around every popup.
57
+ const MENU_BORDER = 1;
58
+ // the scrollbar is drawn *over* the content rather than insetting it
59
+ // (`nodes.js`, SCROLLBAR_WIDTH), so a menu that scrolls reserves the room
60
+ const SCROLLBAR_WIDTH = 6;
61
+
26
62
  function normalizeOption(option) {
27
63
  return typeof option === 'object' && option !== null
28
64
  ? option
29
65
  : { value: option, label: String(option) };
30
66
  }
31
67
 
32
- function Option({ option, selected, active, onPick, onHover, nodeRef }) {
68
+ /**
69
+ * How wide the menu has to be to show its **longest** option, not merely the
70
+ * selected one. Sizing it to the trigger — which is only ever as wide as the
71
+ * current value — left every longer label to wrap inside a fixed 28px row and
72
+ * overlap the option under it.
73
+ *
74
+ * Labels are measured at the size they are painted: a `<text>` that names no
75
+ * size of its own takes the palette's, so this is `theme.fontSize` and not
76
+ * the size of whatever is in the trigger. The selected one is measured bold,
77
+ * which is how `Option` draws it.
78
+ *
79
+ * The result is never narrower than the trigger it hangs off, and never wider
80
+ * than the screen it has to open on — `anchorRect` can slide a popup left to
81
+ * fit, but nothing can rescue one that is wider than the display.
82
+ */
83
+ function menuWidth(node, options, value, scrolls, fontSize) {
84
+ let widest = 0;
85
+ for (const option of options) {
86
+ const { width } = measureLabel(node, option.label, {
87
+ size: fontSize,
88
+ weight: option.value === value ? 'bold' : 'normal',
89
+ });
90
+ if (width > widest) widest = width;
91
+ }
92
+ const chrome =
93
+ (MENU_BORDER + MENU_PAD) * 2 +
94
+ ITEM_PAD_LEFT +
95
+ ITEM_PAD_RIGHT +
96
+ (scrolls ? SCROLLBAR_WIDTH : 0);
97
+ // logical throughout: the trigger's rect divided out of device pixels,
98
+ // the measured labels already logical (measureLabel), the area logical
99
+ const trigger = node.abs.width / node.scale;
100
+ const width = Math.max(trigger, Math.ceil(widest) + chrome);
101
+ const area = anchorArea(node);
102
+ return area ? Math.min(width, area.width) : width;
103
+ }
104
+
105
+ function Option({
106
+ option,
107
+ selected,
108
+ active,
109
+ onPick,
110
+ onHover,
111
+ nodeRef,
112
+ posinset,
113
+ setsize,
114
+ }) {
33
115
  const theme = useTheme();
34
116
  // one highlight, shared by pointer and keyboard: hovering moves the
35
117
  // active index rather than tracking a second, competing state
36
118
  return h(
37
119
  'box',
38
120
  {
121
+ theme,
122
+ role: 'option',
123
+ 'aria-selected': selected,
124
+ 'aria-posinset': posinset,
125
+ 'aria-setsize': setsize,
39
126
  ref: nodeRef,
40
- height: ITEM_HEIGHT,
41
- justifyContent: 'center',
42
- paddingLeft: 10,
43
- cursor: 'pointer',
44
- backgroundColor: active ? theme.hoverBackground : theme.background,
45
127
  onMouseEnter: () => onHover?.(),
46
128
  onClick: () => onPick(option),
129
+ style: {
130
+ height: itemHeight(theme.fontSize),
131
+ justifyContent: 'center',
132
+ paddingLeft: ITEM_PAD_LEFT,
133
+ paddingRight: ITEM_PAD_RIGHT,
134
+ cursor: 'pointer',
135
+ // the menus' pill, for the same reason and by the same rule: an
136
+ // option list and a menu are one surface with rows in it, and two
137
+ // shapes for that would only say the widgets were written apart
138
+ borderRadius: rowRadius(theme, MENU_BORDER, MENU_PAD),
139
+ // nothing at rest — the sheet under it is already that colour, and a
140
+ // rounded fill of the same colour is a coverage mask drawn to change
141
+ // nothing, with four corners it deliberately leaves out
142
+ backgroundColor: active ? theme.hoverBackground : 'transparent',
143
+ },
47
144
  },
48
145
  h(
49
146
  'text',
50
147
  {
51
- color: active ? theme.hoverText : theme.text,
52
- fontWeight: selected ? 'bold' : 'normal',
148
+ style: [
149
+ capTrim,
150
+ {
151
+ color: active ? theme.hoverText : theme.text,
152
+ fontWeight: selected ? 'bold' : 'normal',
153
+ },
154
+ ],
53
155
  },
54
156
  option.label,
55
157
  ),
@@ -57,12 +159,20 @@ function Option({ option, selected, active, onPick, onHover, nodeRef }) {
57
159
  }
58
160
 
59
161
  /**
60
- * <Select value options onChange placeholder width …boxProps> — a dropdown
162
+ * <Select value options onChange placeholder …boxProps> — a dropdown
61
163
  * built on <popup>. The menu is a real override-redirect X11 window
62
164
  * anchored below the trigger (owner window position + trigger rect).
63
165
  * Closes on pick, Escape, toggling the trigger, or focus loss within the
64
166
  * owner window.
65
167
  *
168
+ * The menu opens on the **press**, not the release — the one control here
169
+ * whose answer to a press is more than a tint, and the one where waiting
170
+ * for the release is most obviously wrong: every desktop toolkit drops the
171
+ * list under a held button, and the whole point of a dropdown is to be
172
+ * looking at the options. A press while the menu is up dismisses it through
173
+ * the popup's pointer grab rather than reaching this handler at all, so the
174
+ * two do not fight over the toggle.
175
+ *
66
176
  * Keyboard (the trigger keeps focus while the menu is open — the popup is
67
177
  * override-redirect and never takes it): Up/Down open the menu, then move
68
178
  * the active option with wrapping; Home/End jump to the ends; Enter/Space
@@ -73,8 +183,9 @@ export function Select({
73
183
  value,
74
184
  options = [],
75
185
  onChange,
186
+ name,
76
187
  placeholder = 'Select…',
77
- width,
188
+ style,
78
189
  ...boxProps
79
190
  }) {
80
191
  const theme = useTheme();
@@ -91,18 +202,33 @@ export function Select({
91
202
 
92
203
  const normalized = options.map(normalizeOption);
93
204
  const current = normalized.find((o) => o.value === value);
94
- const menuHeight = Math.min(
95
- normalized.length * ITEM_HEIGHT + 8,
96
- MAX_MENU_HEIGHT,
97
- );
205
+ // Rows are sized from the palette's text size, because that is the size
206
+ // the labels inside them come out at.
207
+ const rowHeight = itemHeight(theme.fontSize);
208
+ const contentHeight = normalized.length * rowHeight + MENU_PAD * 2;
209
+ const menuHeight = Math.min(contentHeight, MAX_MENU_HEIGHT);
210
+ const scrolls = contentHeight > MAX_MENU_HEIGHT;
211
+
212
+ // shared anchoring: also flips the menu above the trigger when there is
213
+ // no room below, and slides it left when the menu is wider than the
214
+ // trigger and would otherwise open off the right edge
215
+ const menuAnchorOptions = () => ({
216
+ placement: 'bottom',
217
+ height: menuHeight + 2,
218
+ width: menuWidth(
219
+ triggerRef.current,
220
+ normalized,
221
+ value,
222
+ scrolls,
223
+ theme.fontSize,
224
+ ),
225
+ });
98
226
 
99
227
  const close = () => setOpen(false);
100
228
  const openMenu = () => {
101
229
  const node = triggerRef.current;
102
230
  if (!node) return;
103
- // shared anchoring: also flips the menu above the trigger when there is
104
- // no room below, instead of opening off the bottom of the screen
105
- const rect = measureAnchor({ placement: 'bottom', height: menuHeight + 2 });
231
+ const rect = measureAnchor(menuAnchorOptions());
106
232
  if (!rect) return;
107
233
  setAnchor(rect);
108
234
  const selected = normalized.findIndex((o) => o.value === value);
@@ -111,9 +237,24 @@ export function Select({
111
237
  };
112
238
  const toggle = () => (open ? close() : openMenu());
113
239
 
240
+ // keeps the menu under the trigger for as long as it is open: a scrolled
241
+ // ancestor, the trigger's own layout moving it (a neighbouring field
242
+ // wrapping to a second line), or the owner window being nudged by the
243
+ // window manager or a script would otherwise leave it hanging in place.
244
+ // If the trigger itself scrolls out of view, the menu closes instead of
245
+ // following it there — a popup is a real window, not something clipped
246
+ // by the trigger's ancestors, so it would otherwise hang over content it
247
+ // no longer points at.
248
+ useAnchorTracking(triggerRef, open, menuAnchorOptions, setAnchor, close);
249
+ // and shut it when the whole window loses focus: the trigger keeps the
250
+ // focus it has, so its own `onBlur` never fires for this
251
+ useDismissOnWindowBlur(triggerRef, open, close);
252
+
253
+ const emit = (next) => onChange?.(changeEvent('select-one', name, next));
254
+
114
255
  const pick = (option) => {
115
256
  close();
116
- if (option.value !== value) onChange?.(option.value);
257
+ if (option.value !== value) emit(option.value);
117
258
  };
118
259
 
119
260
  const move = (delta) => {
@@ -143,7 +284,7 @@ export function Select({
143
284
  // a page is what the menu shows at once, so paging lines up with
144
285
  // what the user can see rather than an arbitrary count
145
286
  if (!open || !count) return;
146
- const page = Math.max(1, Math.floor(MAX_MENU_HEIGHT / ITEM_HEIGHT));
287
+ const page = Math.max(1, Math.floor(MAX_MENU_HEIGHT / rowHeight));
147
288
  const dir = ev.keysym === XK_PAGE_DOWN ? 1 : -1;
148
289
  const from = activeIndex < 0 ? 0 : activeIndex;
149
290
  setActiveIndex(Math.min(count - 1, Math.max(0, from + dir * page)));
@@ -169,7 +310,7 @@ export function Select({
169
310
  const i = typeAhead(char, normalized, current, (o) => o.label);
170
311
  if (i < 0) return;
171
312
  if (open) setActiveIndex(i);
172
- else if (normalized[i].value !== value) onChange?.(normalized[i].value);
313
+ else if (normalized[i].value !== value) emit(normalized[i].value);
173
314
  };
174
315
 
175
316
  // keep the highlighted option visible while arrowing through a menu that
@@ -181,21 +322,13 @@ export function Select({
181
322
  return h(
182
323
  'box',
183
324
  {
325
+ theme,
326
+ role: 'combobox',
327
+ 'aria-expanded': open,
328
+ 'aria-haspopup': 'listbox',
184
329
  ref: triggerRef,
185
330
  focusable: true,
186
- cursor: 'pointer',
187
- flexDirection: 'row',
188
- alignItems: 'center',
189
- gap: 8,
190
- width,
191
- padding: 8,
192
- paddingLeft: 10,
193
- paddingRight: 10,
194
- borderWidth: 1,
195
- borderRadius: 4,
196
- borderColor: focused || open ? theme.borderActive : theme.border,
197
- backgroundColor: theme.background,
198
- onClick: toggle,
331
+ onMouseDown: toggle,
199
332
  onFocus: () => setFocused(true),
200
333
  onBlur: () => {
201
334
  setFocused(false);
@@ -203,63 +336,120 @@ export function Select({
203
336
  },
204
337
  onKeyDown,
205
338
  ...boxProps,
339
+ style: [
340
+ {
341
+ cursor: 'pointer',
342
+ flexDirection: 'row',
343
+ alignItems: 'center',
344
+ gap: 8,
345
+ // The vertical padding is the palette's, the same one a `<Button>`
346
+ // takes, because these are controls of one family and a form puts
347
+ // them in a row together. Horizontally it is its own, tighter
348
+ // number: a dropdown is a field with a value in it, not a button
349
+ // with a word centred on it.
350
+ paddingTop: theme.paddingY,
351
+ paddingBottom: theme.paddingY,
352
+ paddingLeft: 10,
353
+ paddingRight: 10,
354
+ borderWidth: theme.borderWidth,
355
+ borderRadius: theme.radius,
356
+ borderColor: focused || open ? theme.borderFocus : theme.border,
357
+ backgroundColor: theme.surface,
358
+ },
359
+ // Hover and press belong to the trigger while it is *shut*: they say
360
+ // "this opens". Once the menu is down that is answered, and the
361
+ // trigger's job is to read as one surface with the popup hanging off
362
+ // it — so the pointer wandering back over it must not re-tint it.
363
+ //
364
+ // Declaring them conditionally is the mechanism, not a shortcut: a
365
+ // state block always outranks the base style, so there is no colour
366
+ // the open state could put in `backgroundColor` that `:hover` would
367
+ // not overwrite. The only way for open to win is to not be competing.
368
+ !open && {
369
+ ':hover': { backgroundColor: theme.surfaceHover },
370
+ ':active': { backgroundColor: theme.surfaceActive },
371
+ },
372
+ style,
373
+ ],
206
374
  },
207
375
  h(
208
376
  'text',
209
- { color: current ? theme.text : theme.dim },
377
+ { style: [capTrim, { color: current ? theme.text : theme.textMuted }] },
210
378
  current ? current.label : placeholder,
211
379
  ),
212
- h('box', { flexGrow: 1 }),
213
- h('canvas', {
214
- width: 10,
215
- height: 6,
216
- onDraw: (ctx, { width: w, height: hgt }) => {
217
- ctx.fillStyle = theme.dim;
218
- ctx.beginPath();
219
- ctx.moveTo(0, 0);
220
- ctx.lineTo(w, 0);
221
- ctx.lineTo(w / 2, hgt);
222
- ctx.closePath();
223
- ctx.fill();
224
- },
380
+ h('box', { style: { flexGrow: 1 } }),
381
+ // The chevron is exactly as tall as the capitals it stands beside, and
382
+ // for a reason a form depends on: a field, a dropdown and a button in one
383
+ // row have to be one height, and they only are if the tallest thing in
384
+ // each is measured the same way. A glyph taller than the cap band makes
385
+ // the dropdown alone two pixels taller than everything next to it.
386
+ h(Icon, {
387
+ name: 'chevronDown',
388
+ size: capBand(theme.fontSize),
389
+ color: theme.textMuted,
225
390
  }),
226
391
  open &&
227
392
  anchor &&
228
393
  h(
229
394
  'popup',
230
395
  {
396
+ theme,
231
397
  x: anchor.x,
232
398
  y: anchor.y,
233
399
  width: anchor.width,
234
400
  height: menuHeight + 2,
235
- backgroundColor: theme.background,
236
- // a press anywhere else closes the menu, the window frame
237
- // included — see PopupNode's `grab`
401
+ // what this sheet *is* to the compositor: a menu dropping from a
402
+ // control, not the bare `<popup>` default of `popup_menu`
403
+ windowType: 'dropdown_menu',
238
404
  grab: true,
239
405
  onDismiss: close,
406
+ // ARGB where the display has it, so the corners the sheet gives up
407
+ // are the desktop rather than a colour — as the menus are. The
408
+ // window paints nothing itself when it can be seen through: the
409
+ // box below is the whole of the dropdown, and a square fill under
410
+ // it would put the corners straight back.
411
+ transparent: true,
412
+ style: {
413
+ backgroundColor: theme.surface,
414
+ '@supports transparency': { backgroundColor: 'transparent' },
415
+ },
240
416
  },
241
417
  h(
242
418
  'box',
243
419
  {
244
- flexGrow: 1,
245
- // yoga's default flexShrink is 0: without this the frame keeps
246
- // its content height, the scrollview inside it never shrinks to
247
- // the viewport, and a menu longer than MAX_MENU_HEIGHT gets
248
- // clipped by the popup's edge instead of scrolling
249
- flexShrink: 1,
250
- borderWidth: 1,
251
- borderColor: theme.border,
252
- backgroundColor: theme.background,
420
+ style: {
421
+ flexGrow: 1,
422
+ flexShrink: 1,
423
+ borderWidth: MENU_BORDER,
424
+ borderColor: theme.border,
425
+ backgroundColor: theme.surface,
426
+ '@supports transparency': { borderRadius: theme.radiusPopup },
427
+ },
253
428
  },
254
429
  h(
255
- 'scrollview',
256
- { ref: scrollRef, flexGrow: 1, padding: 4 },
430
+ 'box',
431
+ {
432
+ ref: scrollRef,
433
+ role: 'listbox',
434
+ // Not a tab stop, and not a focus target for a press either.
435
+ // A scroll box becomes focusable the moment its content
436
+ // overflows, so a menu long enough to scroll would take focus
437
+ // on mousedown — blurring the trigger, whose onBlur closes the
438
+ // menu, which unmounts the row under the pointer before the
439
+ // release can turn into a click. The trigger owns the keyboard
440
+ // for a Select; this pane is scrolled by wheel and by the
441
+ // arrow keys the trigger already handles.
442
+ focusable: false,
443
+ style: { flexGrow: 1, padding: MENU_PAD, overflow: 'scroll' },
444
+ },
257
445
  normalized.map((option, index) =>
258
446
  h(Option, {
259
447
  key: String(option.value),
260
448
  option,
261
449
  selected: option.value === value,
262
450
  active: index === activeIndex,
451
+ posinset: index + 1,
452
+ setsize: normalized.length,
263
453
  nodeRef: index === activeIndex ? activeRef : undefined,
264
454
  onHover: () => setActiveIndex(index),
265
455
  onPick: pick,