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/Radio.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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)
|
|
44
|
+
if (next !== value) emit(next);
|
|
33
45
|
},
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
);
|
|
46
|
+
};
|
|
47
|
+
}, [value, onChange, name, order]);
|
|
37
48
|
return h(
|
|
38
49
|
'box',
|
|
39
|
-
{
|
|
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 {
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
135
|
+
color: disabled ? theme.textMuted : theme.text,
|
|
93
136
|
}),
|
|
94
137
|
);
|
|
95
138
|
}
|
package/src/components/Select.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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)
|
|
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 /
|
|
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)
|
|
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
|
-
|
|
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.
|
|
377
|
+
{ style: [capTrim, { color: current ? theme.text : theme.textMuted }] },
|
|
210
378
|
current ? current.label : placeholder,
|
|
211
379
|
),
|
|
212
|
-
h('box', { flexGrow: 1 }),
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
236
|
-
//
|
|
237
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
'
|
|
256
|
-
{
|
|
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,
|