react-x11 0.0.1 → 1.2.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/LICENSE +21 -0
- package/README.md +226 -0
- package/package.json +60 -7
- package/src/ClickToComponent.js +179 -0
- package/src/DevToolsIntegration.js +106 -0
- package/src/Reconciler.js +507 -0
- package/src/components/Button.js +63 -0
- package/src/components/Canvas3D.js +28 -0
- package/src/components/Checkbox.js +69 -0
- package/src/components/Dialog.js +138 -0
- package/src/components/Menu.js +644 -0
- package/src/components/ProgressBar.js +48 -0
- package/src/components/Radio.js +95 -0
- package/src/components/Select.js +272 -0
- package/src/components/Slider.js +177 -0
- package/src/components/Switch.js +49 -0
- package/src/components/Tooltip.js +146 -0
- package/src/components/anchor.js +211 -0
- package/src/components/index.js +17 -0
- package/src/components/keys.js +21 -0
- package/src/components/theme.js +66 -0
- package/src/components/typeahead.js +56 -0
- package/src/events.js +584 -0
- package/src/geometry3d.js +223 -0
- package/src/glnodes.js +275 -0
- package/src/index.js +30 -0
- package/src/mat4.js +235 -0
- package/src/nodes.js +1985 -0
- package/src/pointer3d.js +158 -0
- package/src/priority.js +39 -0
- package/src/raycast3d.js +146 -0
- package/src/richnodes.js +436 -0
- package/src/scene3d.js +683 -0
- package/src/styles.js +189 -0
- package/.npmignore +0 -17
- package/combobox.jsx +0 -69
- package/react-x11.js +0 -119
- package/test-canvas.js +0 -98
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Widget components built purely on the host primitives — no reconciler
|
|
2
|
+
// support needed. Plain createElement (no JSX) so the library stays
|
|
3
|
+
// build-step-free for consumers.
|
|
4
|
+
|
|
5
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
6
|
+
import { useTheme } from './theme.js';
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_LABEL_SIZE,
|
|
9
|
+
measureLabel,
|
|
10
|
+
movingToward,
|
|
11
|
+
SAFE_HOVER_DELAY,
|
|
12
|
+
screenPoint,
|
|
13
|
+
useAnchor,
|
|
14
|
+
} from './anchor.js';
|
|
15
|
+
|
|
16
|
+
const h = React.createElement;
|
|
17
|
+
|
|
18
|
+
const TOOLTIP_PADDING_X = 8;
|
|
19
|
+
|
|
20
|
+
const TOOLTIP_PADDING_Y = 4;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* <Tooltip label placement delay>…</Tooltip> — a hover hint in a `<popup>`,
|
|
24
|
+
* so it can extend past the owner window's bounds.
|
|
25
|
+
*
|
|
26
|
+
* Wraps its children in a row box that carries the hover handlers and the
|
|
27
|
+
* anchor ref. Shows after `delay` ms of hover, hides immediately on leave
|
|
28
|
+
* (and on mousedown — a tooltip lingering over a menu you just opened is
|
|
29
|
+
* the classic annoyance). `placement` flips automatically near a screen
|
|
30
|
+
* edge, via the same `useAnchor` math `Select` uses.
|
|
31
|
+
*
|
|
32
|
+
* The popup is sized from the measured label, since a `<popup>` is a real
|
|
33
|
+
* X window and needs its size up front rather than after layout.
|
|
34
|
+
*/
|
|
35
|
+
export function Tooltip({
|
|
36
|
+
label,
|
|
37
|
+
children,
|
|
38
|
+
placement = 'top',
|
|
39
|
+
delay = 500,
|
|
40
|
+
fontSize = DEFAULT_LABEL_SIZE,
|
|
41
|
+
...boxProps
|
|
42
|
+
}) {
|
|
43
|
+
const theme = useTheme();
|
|
44
|
+
const ref = useRef(null);
|
|
45
|
+
const measureAnchor = useAnchor(ref);
|
|
46
|
+
const [rect, setRect] = useState(null);
|
|
47
|
+
const timer = useRef(null);
|
|
48
|
+
|
|
49
|
+
const cancel = () => {
|
|
50
|
+
if (timer.current) {
|
|
51
|
+
clearTimeout(timer.current);
|
|
52
|
+
timer.current = null;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const hide = () => {
|
|
56
|
+
cancel();
|
|
57
|
+
setRect(null);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// safe-polygon hover (docs/components.md): leaving the trigger *toward*
|
|
61
|
+
// the tooltip keeps it up, so a tooltip with content in it can be
|
|
62
|
+
// reached instead of vanishing the moment the pointer moves
|
|
63
|
+
const apex = useRef(null);
|
|
64
|
+
const hideSoon = () => {
|
|
65
|
+
cancel();
|
|
66
|
+
timer.current = setTimeout(() => {
|
|
67
|
+
timer.current = null;
|
|
68
|
+
setRect(null);
|
|
69
|
+
}, SAFE_HOVER_DELAY);
|
|
70
|
+
};
|
|
71
|
+
const onMouseMove = (ev) => {
|
|
72
|
+
apex.current = screenPoint(ev) ?? apex.current;
|
|
73
|
+
};
|
|
74
|
+
const onMouseLeave = (ev) => {
|
|
75
|
+
if (rect && movingToward(screenPoint(ev), apex.current, rect)) hideSoon();
|
|
76
|
+
else hide();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// a pending timer must not outlive the component
|
|
80
|
+
useEffect(() => cancel, []);
|
|
81
|
+
|
|
82
|
+
const show = () => {
|
|
83
|
+
const node = ref.current;
|
|
84
|
+
if (!node || !label) return;
|
|
85
|
+
const text = measureLabel(node, label, { size: fontSize });
|
|
86
|
+
const width = Math.ceil(text.width) + TOOLTIP_PADDING_X * 2 + 2;
|
|
87
|
+
const height = Math.ceil(text.height) + TOOLTIP_PADDING_Y * 2 + 2;
|
|
88
|
+
const next = measureAnchor({ placement, align: 'center', width, height });
|
|
89
|
+
if (next) setRect(next);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const onMouseEnter = () => {
|
|
93
|
+
cancel();
|
|
94
|
+
timer.current = setTimeout(() => {
|
|
95
|
+
timer.current = null;
|
|
96
|
+
show();
|
|
97
|
+
}, delay);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return h(
|
|
101
|
+
'box',
|
|
102
|
+
{
|
|
103
|
+
ref,
|
|
104
|
+
flexDirection: 'row',
|
|
105
|
+
alignItems: 'center',
|
|
106
|
+
onMouseEnter,
|
|
107
|
+
onMouseMove,
|
|
108
|
+
onMouseLeave,
|
|
109
|
+
onMouseDown: hide,
|
|
110
|
+
...boxProps,
|
|
111
|
+
},
|
|
112
|
+
children,
|
|
113
|
+
rect &&
|
|
114
|
+
h(
|
|
115
|
+
'popup',
|
|
116
|
+
{
|
|
117
|
+
x: rect.x,
|
|
118
|
+
y: rect.y,
|
|
119
|
+
width: rect.width,
|
|
120
|
+
height: rect.height,
|
|
121
|
+
windowType: 'tooltip',
|
|
122
|
+
backgroundColor: theme.text,
|
|
123
|
+
},
|
|
124
|
+
h(
|
|
125
|
+
'box',
|
|
126
|
+
{
|
|
127
|
+
// the pointer reaching the tooltip keeps it up; leaving it
|
|
128
|
+
// dismisses, as if the trigger had been left
|
|
129
|
+
onMouseEnter: cancel,
|
|
130
|
+
onMouseLeave: hide,
|
|
131
|
+
flexGrow: 1,
|
|
132
|
+
borderWidth: 1,
|
|
133
|
+
borderColor: theme.text,
|
|
134
|
+
borderRadius: 3,
|
|
135
|
+
backgroundColor: theme.text,
|
|
136
|
+
justifyContent: 'center',
|
|
137
|
+
paddingLeft: TOOLTIP_PADDING_X,
|
|
138
|
+
paddingRight: TOOLTIP_PADDING_X,
|
|
139
|
+
},
|
|
140
|
+
h('text', { color: theme.background, fontSize }, label),
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- menus ------------------------------------------------------------------
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Popup geometry: where to put a <popup> anchored to a drawn node, and
|
|
2
|
+
// how big to make it around a measured label.
|
|
3
|
+
|
|
4
|
+
import { useCallback } from 'react';
|
|
5
|
+
|
|
6
|
+
/** The X screen the node's window lives on, if reachable (the smoke-test
|
|
7
|
+
* mock app has no screen geometry — callers must cope with null). */
|
|
8
|
+
export function screenOf(node) {
|
|
9
|
+
const app = node?.app;
|
|
10
|
+
const screen = (app?.display ?? app?.X?.display)?.screen?.[0];
|
|
11
|
+
return screen?.pixel_width ? screen : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where to put a `<popup>` anchored to a drawn node, in **screen**
|
|
16
|
+
* coordinates: the owner window's position plus the node's laid-out rect.
|
|
17
|
+
*
|
|
18
|
+
* `placement` is a preference, not a promise — a menu near the bottom of
|
|
19
|
+
* the screen flips above its trigger rather than opening off-screen, and
|
|
20
|
+
* the result is clamped into the screen either way. The chosen side comes
|
|
21
|
+
* back as `placement` so the caller can style accordingly.
|
|
22
|
+
*/
|
|
23
|
+
export function anchorRect(node, options = {}) {
|
|
24
|
+
if (!node?.abs) return null;
|
|
25
|
+
const {
|
|
26
|
+
placement = 'bottom',
|
|
27
|
+
align = 'start',
|
|
28
|
+
offset = 2,
|
|
29
|
+
width = node.abs.width,
|
|
30
|
+
height = 0,
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
const win = node.root?.window;
|
|
34
|
+
const ax = (win?.x ?? 0) + node.abs.x;
|
|
35
|
+
const ay = (win?.y ?? 0) + node.abs.y;
|
|
36
|
+
const aw = node.abs.width;
|
|
37
|
+
const ah = node.abs.height;
|
|
38
|
+
|
|
39
|
+
const screen = screenOf(node);
|
|
40
|
+
const sw = screen?.pixel_width;
|
|
41
|
+
const sh = screen?.pixel_height;
|
|
42
|
+
|
|
43
|
+
const alignAlong = (start, size, extent) =>
|
|
44
|
+
align === 'center'
|
|
45
|
+
? start + (size - extent) / 2
|
|
46
|
+
: align === 'end'
|
|
47
|
+
? start + size - extent
|
|
48
|
+
: start;
|
|
49
|
+
|
|
50
|
+
let side = placement;
|
|
51
|
+
let x;
|
|
52
|
+
let y;
|
|
53
|
+
|
|
54
|
+
if (side === 'bottom' || side === 'top') {
|
|
55
|
+
const below = ay + ah + offset;
|
|
56
|
+
const above = ay - height - offset;
|
|
57
|
+
if (side === 'bottom' && sh != null && below + height > sh && above >= 0) {
|
|
58
|
+
side = 'top';
|
|
59
|
+
} else if (
|
|
60
|
+
side === 'top' &&
|
|
61
|
+
above < 0 &&
|
|
62
|
+
(sh == null || below + height <= sh)
|
|
63
|
+
) {
|
|
64
|
+
side = 'bottom';
|
|
65
|
+
}
|
|
66
|
+
y = side === 'bottom' ? below : above;
|
|
67
|
+
x = alignAlong(ax, aw, width);
|
|
68
|
+
} else {
|
|
69
|
+
const after = ax + aw + offset;
|
|
70
|
+
const before = ax - width - offset;
|
|
71
|
+
if (side === 'right' && sw != null && after + width > sw && before >= 0) {
|
|
72
|
+
side = 'left';
|
|
73
|
+
} else if (
|
|
74
|
+
side === 'left' &&
|
|
75
|
+
before < 0 &&
|
|
76
|
+
(sw == null || after + width <= sw)
|
|
77
|
+
) {
|
|
78
|
+
side = 'right';
|
|
79
|
+
}
|
|
80
|
+
x = side === 'right' ? after : before;
|
|
81
|
+
y = alignAlong(ay, ah, height);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (sw != null) x = Math.max(0, Math.min(x, sw - width));
|
|
85
|
+
if (sh != null && height) y = Math.max(0, Math.min(y, sh - height));
|
|
86
|
+
|
|
87
|
+
return { x: Math.round(x), y: Math.round(y), width, height, placement: side };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Where to put a `<popup>` of this size **centred over the owner window**,
|
|
92
|
+
* in screen coordinates, clamped into the screen. A dialog is anchored to
|
|
93
|
+
* the window rather than to a widget, which is the one placement
|
|
94
|
+
* `anchorRect` cannot express.
|
|
95
|
+
*/
|
|
96
|
+
export function centerRect(node, { width, height }) {
|
|
97
|
+
if (!node) return null;
|
|
98
|
+
const win = node.root?.window;
|
|
99
|
+
const ww = win?.width ?? width;
|
|
100
|
+
const wh = win?.height ?? height;
|
|
101
|
+
let x = (win?.x ?? 0) + (ww - width) / 2;
|
|
102
|
+
let y = (win?.y ?? 0) + (wh - height) / 2;
|
|
103
|
+
|
|
104
|
+
const screen = screenOf(node);
|
|
105
|
+
if (screen) {
|
|
106
|
+
x = Math.max(0, Math.min(x, screen.pixel_width - width));
|
|
107
|
+
y = Math.max(0, Math.min(y, screen.pixel_height - height));
|
|
108
|
+
}
|
|
109
|
+
return { x: Math.round(x), y: Math.round(y), width, height };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* useAnchor(ref) — stable `measure(options)` returning `anchorRect` for the
|
|
114
|
+
* referenced node. The anchoring math `Select` used to inline, shared with
|
|
115
|
+
* `Tooltip` and anything else that hangs a `<popup>` off a drawn node.
|
|
116
|
+
*/
|
|
117
|
+
export function useAnchor(ref) {
|
|
118
|
+
return useCallback((options) => anchorRect(ref.current, options), [ref]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Measured size of a single-line label, for sizing a popup around it.
|
|
122
|
+
* Falls back to a rough estimate where no font stack is available. */
|
|
123
|
+
export function measureLabel(node, text, style) {
|
|
124
|
+
const fonts = node?.app?.fonts;
|
|
125
|
+
const size = style?.size ?? DEFAULT_LABEL_SIZE;
|
|
126
|
+
if (!fonts?.layout) {
|
|
127
|
+
return { width: String(text).length * size * 0.55, height: size * 1.4 };
|
|
128
|
+
}
|
|
129
|
+
const layout = fonts.layout(String(text), {
|
|
130
|
+
family: style?.family ?? 'sans-serif',
|
|
131
|
+
size,
|
|
132
|
+
weight: style?.weight ?? 'normal',
|
|
133
|
+
});
|
|
134
|
+
return { width: layout.width, height: layout.height };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const DEFAULT_LABEL_SIZE = 13;
|
|
138
|
+
|
|
139
|
+
/** How long a hover change is held back while the pointer crosses the
|
|
140
|
+
* polygon — long enough to be forgiving, short enough not to feel stuck. */
|
|
141
|
+
export const SAFE_HOVER_DELAY = 320;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* "Safe polygon" hover, after
|
|
145
|
+
* [floating-ui](https://floating-ui.com/docs/usehover#safepolygon).
|
|
146
|
+
*
|
|
147
|
+
* A submenu opens to the side of its parent row, so reaching it means
|
|
148
|
+
* moving the pointer *diagonally* across the rows in between — and those
|
|
149
|
+
* rows would each take the hover and close the submenu being aimed at. The
|
|
150
|
+
* fix is to treat the triangle between where the pointer was and the near
|
|
151
|
+
* edge of the child surface as "still hovering the parent": while the
|
|
152
|
+
* pointer is inside it, hover changes are held back.
|
|
153
|
+
*
|
|
154
|
+
* `apex` is where the pointer was while it was still over the parent (its
|
|
155
|
+
* exit point, near enough), `rect` the child popup — both in **screen**
|
|
156
|
+
* coordinates, because parent and child are different X windows.
|
|
157
|
+
*/
|
|
158
|
+
export function movingToward(point, apex, rect, buffer = 8) {
|
|
159
|
+
if (!point || !apex || !rect?.width) return false;
|
|
160
|
+
return pointInPolygon(point, safePolygon(apex, rect, buffer));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The triangle from `apex` to the child's near edge, grown by `buffer`. */
|
|
164
|
+
export function safePolygon(apex, rect, buffer = 8) {
|
|
165
|
+
const left = rect.x;
|
|
166
|
+
const right = rect.x + rect.width;
|
|
167
|
+
const top = rect.y;
|
|
168
|
+
const bottom = rect.y + rect.height;
|
|
169
|
+
|
|
170
|
+
// the edge the pointer has to cross to reach the child
|
|
171
|
+
let a;
|
|
172
|
+
let b;
|
|
173
|
+
if (apex.x <= left) {
|
|
174
|
+
a = { x: left, y: top - buffer };
|
|
175
|
+
b = { x: left, y: bottom + buffer };
|
|
176
|
+
} else if (apex.x >= right) {
|
|
177
|
+
a = { x: right, y: top - buffer };
|
|
178
|
+
b = { x: right, y: bottom + buffer };
|
|
179
|
+
} else if (apex.y <= top) {
|
|
180
|
+
a = { x: left - buffer, y: top };
|
|
181
|
+
b = { x: right + buffer, y: top };
|
|
182
|
+
} else {
|
|
183
|
+
a = { x: left - buffer, y: bottom };
|
|
184
|
+
b = { x: right + buffer, y: bottom };
|
|
185
|
+
}
|
|
186
|
+
return [apex, a, b];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Ray casting; the polygon is a triangle here but the test is general. */
|
|
190
|
+
export function pointInPolygon(point, polygon) {
|
|
191
|
+
let inside = false;
|
|
192
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
193
|
+
const pi = polygon[i];
|
|
194
|
+
const pj = polygon[j];
|
|
195
|
+
const straddles = pi.y > point.y !== pj.y > point.y;
|
|
196
|
+
if (
|
|
197
|
+
straddles &&
|
|
198
|
+
point.x < ((pj.x - pi.x) * (point.y - pi.y)) / (pj.y - pi.y) + pi.x
|
|
199
|
+
) {
|
|
200
|
+
inside = !inside;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return inside;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Pointer position of an event in screen coordinates, or null. */
|
|
207
|
+
export function screenPoint(ev) {
|
|
208
|
+
const native = ev?.nativeEvent;
|
|
209
|
+
if (native?.rootx == null || native?.rooty == null) return null;
|
|
210
|
+
return { x: native.rootx, y: native.rooty };
|
|
211
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Public widget components. Split out of a single components.js; the
|
|
2
|
+
// modules here are grouped by widget, with the shared plumbing in
|
|
3
|
+
// theme.js (palette + control props), anchor.js (popup geometry),
|
|
4
|
+
// typeahead.js and keys.js.
|
|
5
|
+
export { ThemeProvider, SelectThemeProvider } from './theme.js';
|
|
6
|
+
export { anchorRect, centerRect, useAnchor } from './anchor.js';
|
|
7
|
+
export { Button } from './Button.js';
|
|
8
|
+
export { Dialog } from './Dialog.js';
|
|
9
|
+
export { Checkbox } from './Checkbox.js';
|
|
10
|
+
export { Radio, RadioGroup } from './Radio.js';
|
|
11
|
+
export { Switch } from './Switch.js';
|
|
12
|
+
export { ProgressBar } from './ProgressBar.js';
|
|
13
|
+
export { Slider } from './Slider.js';
|
|
14
|
+
export { Tooltip } from './Tooltip.js';
|
|
15
|
+
export { ContextMenu, MenuBar } from './Menu.js';
|
|
16
|
+
export { Select } from './Select.js';
|
|
17
|
+
export { Canvas3D } from './Canvas3D.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// X11 keysyms used by the widget keyboard handlers.
|
|
2
|
+
|
|
3
|
+
export const XK_RETURN = 0xff0d;
|
|
4
|
+
|
|
5
|
+
export const XK_ESCAPE = 0xff1b;
|
|
6
|
+
|
|
7
|
+
export const XK_HOME = 0xff50;
|
|
8
|
+
|
|
9
|
+
export const XK_LEFT = 0xff51;
|
|
10
|
+
|
|
11
|
+
export const XK_UP = 0xff52;
|
|
12
|
+
|
|
13
|
+
export const XK_RIGHT = 0xff53;
|
|
14
|
+
|
|
15
|
+
export const XK_DOWN = 0xff54;
|
|
16
|
+
|
|
17
|
+
export const XK_PAGE_UP = 0xff55;
|
|
18
|
+
|
|
19
|
+
export const XK_PAGE_DOWN = 0xff56;
|
|
20
|
+
|
|
21
|
+
export const XK_END = 0xff57;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Widget components built purely on the host primitives — no reconciler
|
|
2
|
+
// support needed. Plain createElement (no JSX) so the library stays
|
|
3
|
+
// build-step-free for consumers.
|
|
4
|
+
|
|
5
|
+
import React, { useContext, useState } from 'react';
|
|
6
|
+
import { XK_RETURN } from './keys.js';
|
|
7
|
+
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
|
|
10
|
+
const DefaultTheme = {
|
|
11
|
+
border: '#b2bec3',
|
|
12
|
+
borderActive: '#2980b9',
|
|
13
|
+
background: 'white',
|
|
14
|
+
text: '#2d3436',
|
|
15
|
+
dim: '#7f8c8d',
|
|
16
|
+
hoverBackground: '#2980b9',
|
|
17
|
+
hoverText: 'white',
|
|
18
|
+
accent: '#2980b9',
|
|
19
|
+
accentHover: '#1f6693',
|
|
20
|
+
accentText: 'white',
|
|
21
|
+
surfaceHover: '#f1f2f6',
|
|
22
|
+
track: '#dfe6e9',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ThemeContext = React.createContext(DefaultTheme);
|
|
26
|
+
|
|
27
|
+
/** Themes all widgets; partial palettes merge over the defaults. */
|
|
28
|
+
export const ThemeProvider = ThemeContext.Provider;
|
|
29
|
+
|
|
30
|
+
export const SelectThemeProvider = ThemeContext.Provider; // back-compat alias
|
|
31
|
+
|
|
32
|
+
export function useTheme() {
|
|
33
|
+
const theme = useContext(ThemeContext);
|
|
34
|
+
return theme === DefaultTheme ? theme : { ...DefaultTheme, ...theme };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Shared interactive-control plumbing: hover/focus state plus the box
|
|
38
|
+
* props wiring them, click + Space/Enter activation. */
|
|
39
|
+
export function useControl(disabled, onActivate) {
|
|
40
|
+
const [hover, setHover] = useState(false);
|
|
41
|
+
const [focused, setFocused] = useState(false);
|
|
42
|
+
const props = disabled
|
|
43
|
+
? {}
|
|
44
|
+
: {
|
|
45
|
+
focusable: true,
|
|
46
|
+
cursor: 'pointer',
|
|
47
|
+
onMouseEnter: () => setHover(true),
|
|
48
|
+
onMouseLeave: () => setHover(false),
|
|
49
|
+
onFocus: () => setFocused(true),
|
|
50
|
+
onBlur: () => setFocused(false),
|
|
51
|
+
onClick: () => onActivate?.(),
|
|
52
|
+
onKeyDown: (ev) => {
|
|
53
|
+
if (ev.codepoint === 32 || ev.keysym === XK_RETURN) onActivate?.();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
return { hover: hover && !disabled, focused: focused && !disabled, props };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** String/number children become a <text>; elements pass through. */
|
|
60
|
+
export function labelContent(children, textProps) {
|
|
61
|
+
return React.Children.map(children, (child) =>
|
|
62
|
+
typeof child === 'string' || typeof child === 'number'
|
|
63
|
+
? h('text', textProps, child)
|
|
64
|
+
: child,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Type-ahead matching shared by Select and the menus.
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
export const TYPE_AHEAD_TIMEOUT = 700;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Type-ahead: typing letters jumps to the entry whose label starts with
|
|
9
|
+
* them. Shared by `Select` and the menus.
|
|
10
|
+
*
|
|
11
|
+
* Keystrokes within `timeout` accumulate into one query, so "ca" finds
|
|
12
|
+
* Carrot rather than jumping to Apple then Carrot. Two behaviours the
|
|
13
|
+
* platform conventions call for:
|
|
14
|
+
*
|
|
15
|
+
* - a growing query searches from the *current* entry, so refining a
|
|
16
|
+
* prefix keeps you on it while it still matches;
|
|
17
|
+
* - repeating one letter ("c", "c", "c") cycles through the entries
|
|
18
|
+
* starting with it instead of sticking on the first.
|
|
19
|
+
*
|
|
20
|
+
* Returns the matching index, or -1.
|
|
21
|
+
*/
|
|
22
|
+
export function useTypeAhead(timeout = TYPE_AHEAD_TIMEOUT) {
|
|
23
|
+
const state = useRef({ text: '', at: 0 });
|
|
24
|
+
return useCallback(
|
|
25
|
+
(char, items, current, labelOf, selectable) => {
|
|
26
|
+
if (!char || char.length !== 1) return -1;
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
const s = state.current;
|
|
29
|
+
s.text = now - s.at > timeout ? char : s.text + char;
|
|
30
|
+
s.at = now;
|
|
31
|
+
|
|
32
|
+
const query = s.text.toLowerCase();
|
|
33
|
+
const cycling = query.length > 1 && /^(.)\1+$/.test(query);
|
|
34
|
+
const needle = cycling ? query[0] : query;
|
|
35
|
+
const from =
|
|
36
|
+
cycling || query.length === 1 ? (current ?? -1) + 1 : (current ?? 0);
|
|
37
|
+
|
|
38
|
+
const n = items.length;
|
|
39
|
+
for (let k = 0; k < n; k++) {
|
|
40
|
+
const i = (((from + k) % n) + n) % n;
|
|
41
|
+
const item = items[i];
|
|
42
|
+
if (selectable && !selectable(item)) continue;
|
|
43
|
+
const label = String(labelOf(item) ?? '').toLowerCase();
|
|
44
|
+
if (label.startsWith(needle)) return i;
|
|
45
|
+
}
|
|
46
|
+
return -1;
|
|
47
|
+
},
|
|
48
|
+
[timeout],
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A printable character usable for type-ahead. Space is excluded: it
|
|
53
|
+
* activates the focused entry everywhere in this widget set. */
|
|
54
|
+
export function typeAheadChar(ev) {
|
|
55
|
+
return ev.key && ev.key.length === 1 && ev.codepoint > 32 ? ev.key : null;
|
|
56
|
+
}
|