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,95 @@
|
|
|
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, useEffect, useMemo, useRef } from 'react';
|
|
6
|
+
import { labelContent, useControl, useTheme } from './theme.js';
|
|
7
|
+
import { XK_DOWN, XK_LEFT, XK_RIGHT, XK_UP } from './keys.js';
|
|
8
|
+
|
|
9
|
+
const h = React.createElement;
|
|
10
|
+
|
|
11
|
+
const RadioGroupContext = React.createContext(null);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* <RadioGroup value onChange>…<Radio value=…>label</Radio>…</RadioGroup> —
|
|
15
|
+
* exclusive choice. Arrow keys move the selection through the group in
|
|
16
|
+
* mount order (wrapping); click or Space selects the focused radio.
|
|
17
|
+
*/
|
|
18
|
+
export function RadioGroup({ value, onChange, children, ...boxProps }) {
|
|
19
|
+
const order = useRef([]).current;
|
|
20
|
+
const ctx = useMemo(
|
|
21
|
+
() => ({
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
register: (v) => {
|
|
25
|
+
order.push(v);
|
|
26
|
+
return () => order.splice(order.indexOf(v), 1);
|
|
27
|
+
},
|
|
28
|
+
move: (delta) => {
|
|
29
|
+
if (order.length === 0) return;
|
|
30
|
+
const i = order.indexOf(value);
|
|
31
|
+
const next = order[(i + delta + order.length) % order.length];
|
|
32
|
+
if (next !== value) onChange?.(next);
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
[value, onChange, order],
|
|
36
|
+
);
|
|
37
|
+
return h(
|
|
38
|
+
'box',
|
|
39
|
+
{ gap: 6, ...boxProps },
|
|
40
|
+
h(RadioGroupContext.Provider, { value: ctx }, children),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function Radio({ value, children, label, disabled = false }) {
|
|
45
|
+
const theme = useTheme();
|
|
46
|
+
const group = useContext(RadioGroupContext);
|
|
47
|
+
if (!group) {
|
|
48
|
+
throw new Error('react-x11: <Radio> must be inside a <RadioGroup>');
|
|
49
|
+
}
|
|
50
|
+
useEffect(() => group.register(value), [group, value]);
|
|
51
|
+
const selected = group.value === value;
|
|
52
|
+
const { focused, props } = useControl(disabled, () => {
|
|
53
|
+
if (!selected) group.onChange?.(value);
|
|
54
|
+
});
|
|
55
|
+
const onKeyDown = props.onKeyDown;
|
|
56
|
+
if (onKeyDown) {
|
|
57
|
+
props.onKeyDown = (ev) => {
|
|
58
|
+
if (ev.keysym === XK_DOWN || ev.keysym === XK_RIGHT) group.move(1);
|
|
59
|
+
else if (ev.keysym === XK_UP || ev.keysym === XK_LEFT) group.move(-1);
|
|
60
|
+
else onKeyDown(ev);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const fill = disabled ? theme.dim : theme.accent;
|
|
64
|
+
return h(
|
|
65
|
+
'box',
|
|
66
|
+
{ flexDirection: 'row', alignItems: 'center', gap: 8, ...props },
|
|
67
|
+
h(
|
|
68
|
+
'box',
|
|
69
|
+
{
|
|
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',
|
|
82
|
+
},
|
|
83
|
+
selected &&
|
|
84
|
+
h('box', {
|
|
85
|
+
width: 8,
|
|
86
|
+
height: 8,
|
|
87
|
+
borderRadius: 4,
|
|
88
|
+
backgroundColor: fill,
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
labelContent(children ?? label, {
|
|
92
|
+
color: disabled ? theme.dim : theme.text,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
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 { useAnchor } from './anchor.js';
|
|
8
|
+
import { typeAheadChar, useTypeAhead } from './typeahead.js';
|
|
9
|
+
import {
|
|
10
|
+
XK_DOWN,
|
|
11
|
+
XK_END,
|
|
12
|
+
XK_ESCAPE,
|
|
13
|
+
XK_HOME,
|
|
14
|
+
XK_PAGE_DOWN,
|
|
15
|
+
XK_PAGE_UP,
|
|
16
|
+
XK_RETURN,
|
|
17
|
+
XK_UP,
|
|
18
|
+
} from './keys.js';
|
|
19
|
+
|
|
20
|
+
const h = React.createElement;
|
|
21
|
+
|
|
22
|
+
const ITEM_HEIGHT = 28;
|
|
23
|
+
|
|
24
|
+
const MAX_MENU_HEIGHT = 220;
|
|
25
|
+
|
|
26
|
+
function normalizeOption(option) {
|
|
27
|
+
return typeof option === 'object' && option !== null
|
|
28
|
+
? option
|
|
29
|
+
: { value: option, label: String(option) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Option({ option, selected, active, onPick, onHover, nodeRef }) {
|
|
33
|
+
const theme = useTheme();
|
|
34
|
+
// one highlight, shared by pointer and keyboard: hovering moves the
|
|
35
|
+
// active index rather than tracking a second, competing state
|
|
36
|
+
return h(
|
|
37
|
+
'box',
|
|
38
|
+
{
|
|
39
|
+
ref: nodeRef,
|
|
40
|
+
height: ITEM_HEIGHT,
|
|
41
|
+
justifyContent: 'center',
|
|
42
|
+
paddingLeft: 10,
|
|
43
|
+
cursor: 'pointer',
|
|
44
|
+
backgroundColor: active ? theme.hoverBackground : theme.background,
|
|
45
|
+
onMouseEnter: () => onHover?.(),
|
|
46
|
+
onClick: () => onPick(option),
|
|
47
|
+
},
|
|
48
|
+
h(
|
|
49
|
+
'text',
|
|
50
|
+
{
|
|
51
|
+
color: active ? theme.hoverText : theme.text,
|
|
52
|
+
fontWeight: selected ? 'bold' : 'normal',
|
|
53
|
+
},
|
|
54
|
+
option.label,
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* <Select value options onChange placeholder width …boxProps> — a dropdown
|
|
61
|
+
* built on <popup>. The menu is a real override-redirect X11 window
|
|
62
|
+
* anchored below the trigger (owner window position + trigger rect).
|
|
63
|
+
* Closes on pick, Escape, toggling the trigger, or focus loss within the
|
|
64
|
+
* owner window.
|
|
65
|
+
*
|
|
66
|
+
* Keyboard (the trigger keeps focus while the menu is open — the popup is
|
|
67
|
+
* override-redirect and never takes it): Up/Down open the menu, then move
|
|
68
|
+
* the active option with wrapping; Home/End jump to the ends; Enter/Space
|
|
69
|
+
* pick the active option (or open the menu when closed); Escape closes.
|
|
70
|
+
* The active option is scrolled into view.
|
|
71
|
+
*/
|
|
72
|
+
export function Select({
|
|
73
|
+
value,
|
|
74
|
+
options = [],
|
|
75
|
+
onChange,
|
|
76
|
+
placeholder = 'Select…',
|
|
77
|
+
width,
|
|
78
|
+
...boxProps
|
|
79
|
+
}) {
|
|
80
|
+
const theme = useTheme();
|
|
81
|
+
const [open, setOpen] = useState(false);
|
|
82
|
+
const [anchor, setAnchor] = useState(null);
|
|
83
|
+
const [focused, setFocused] = useState(false);
|
|
84
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
85
|
+
const triggerRef = useRef(null);
|
|
86
|
+
const scrollRef = useRef(null);
|
|
87
|
+
const activeRef = useRef(null);
|
|
88
|
+
|
|
89
|
+
const measureAnchor = useAnchor(triggerRef);
|
|
90
|
+
const typeAhead = useTypeAhead();
|
|
91
|
+
|
|
92
|
+
const normalized = options.map(normalizeOption);
|
|
93
|
+
const current = normalized.find((o) => o.value === value);
|
|
94
|
+
const menuHeight = Math.min(
|
|
95
|
+
normalized.length * ITEM_HEIGHT + 8,
|
|
96
|
+
MAX_MENU_HEIGHT,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const close = () => setOpen(false);
|
|
100
|
+
const openMenu = () => {
|
|
101
|
+
const node = triggerRef.current;
|
|
102
|
+
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 });
|
|
106
|
+
if (!rect) return;
|
|
107
|
+
setAnchor(rect);
|
|
108
|
+
const selected = normalized.findIndex((o) => o.value === value);
|
|
109
|
+
setActiveIndex(selected >= 0 ? selected : 0);
|
|
110
|
+
setOpen(true);
|
|
111
|
+
};
|
|
112
|
+
const toggle = () => (open ? close() : openMenu());
|
|
113
|
+
|
|
114
|
+
const pick = (option) => {
|
|
115
|
+
close();
|
|
116
|
+
if (option.value !== value) onChange?.(option.value);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const move = (delta) => {
|
|
120
|
+
const count = normalized.length;
|
|
121
|
+
if (!count) return;
|
|
122
|
+
setActiveIndex((i) => (i < 0 ? 0 : (i + delta + count) % count));
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const onKeyDown = (ev) => {
|
|
126
|
+
const count = normalized.length;
|
|
127
|
+
switch (ev.keysym) {
|
|
128
|
+
case XK_ESCAPE:
|
|
129
|
+
if (open) close();
|
|
130
|
+
return;
|
|
131
|
+
case XK_DOWN:
|
|
132
|
+
case XK_UP:
|
|
133
|
+
if (open) move(ev.keysym === XK_DOWN ? 1 : -1);
|
|
134
|
+
else openMenu();
|
|
135
|
+
return;
|
|
136
|
+
case XK_HOME:
|
|
137
|
+
case XK_END:
|
|
138
|
+
if (open && count)
|
|
139
|
+
setActiveIndex(ev.keysym === XK_HOME ? 0 : count - 1);
|
|
140
|
+
return;
|
|
141
|
+
case XK_PAGE_UP:
|
|
142
|
+
case XK_PAGE_DOWN: {
|
|
143
|
+
// a page is what the menu shows at once, so paging lines up with
|
|
144
|
+
// what the user can see rather than an arbitrary count
|
|
145
|
+
if (!open || !count) return;
|
|
146
|
+
const page = Math.max(1, Math.floor(MAX_MENU_HEIGHT / ITEM_HEIGHT));
|
|
147
|
+
const dir = ev.keysym === XK_PAGE_DOWN ? 1 : -1;
|
|
148
|
+
const from = activeIndex < 0 ? 0 : activeIndex;
|
|
149
|
+
setActiveIndex(Math.min(count - 1, Math.max(0, from + dir * page)));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
default:
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
if (ev.codepoint === 32 || ev.keysym === XK_RETURN) {
|
|
156
|
+
const option = open ? normalized[activeIndex] : null;
|
|
157
|
+
if (option) pick(option);
|
|
158
|
+
else toggle();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// type-ahead: with the menu open it moves the highlight; closed, it
|
|
163
|
+
// changes the value outright, the way a native select does
|
|
164
|
+
const char = typeAheadChar(ev);
|
|
165
|
+
if (!char) return;
|
|
166
|
+
const current = open
|
|
167
|
+
? activeIndex
|
|
168
|
+
: normalized.findIndex((o) => o.value === value);
|
|
169
|
+
const i = typeAhead(char, normalized, current, (o) => o.label);
|
|
170
|
+
if (i < 0) return;
|
|
171
|
+
if (open) setActiveIndex(i);
|
|
172
|
+
else if (normalized[i].value !== value) onChange?.(normalized[i].value);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// keep the highlighted option visible while arrowing through a menu that
|
|
176
|
+
// overflows MAX_MENU_HEIGHT (resolved on the popup's next layout pass)
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (open) scrollRef.current?.scrollIntoView(activeRef.current);
|
|
179
|
+
}, [open, activeIndex]);
|
|
180
|
+
|
|
181
|
+
return h(
|
|
182
|
+
'box',
|
|
183
|
+
{
|
|
184
|
+
ref: triggerRef,
|
|
185
|
+
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,
|
|
199
|
+
onFocus: () => setFocused(true),
|
|
200
|
+
onBlur: () => {
|
|
201
|
+
setFocused(false);
|
|
202
|
+
close();
|
|
203
|
+
},
|
|
204
|
+
onKeyDown,
|
|
205
|
+
...boxProps,
|
|
206
|
+
},
|
|
207
|
+
h(
|
|
208
|
+
'text',
|
|
209
|
+
{ color: current ? theme.text : theme.dim },
|
|
210
|
+
current ? current.label : placeholder,
|
|
211
|
+
),
|
|
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
|
+
},
|
|
225
|
+
}),
|
|
226
|
+
open &&
|
|
227
|
+
anchor &&
|
|
228
|
+
h(
|
|
229
|
+
'popup',
|
|
230
|
+
{
|
|
231
|
+
x: anchor.x,
|
|
232
|
+
y: anchor.y,
|
|
233
|
+
width: anchor.width,
|
|
234
|
+
height: menuHeight + 2,
|
|
235
|
+
backgroundColor: theme.background,
|
|
236
|
+
// a press anywhere else closes the menu, the window frame
|
|
237
|
+
// included — see PopupNode's `grab`
|
|
238
|
+
grab: true,
|
|
239
|
+
onDismiss: close,
|
|
240
|
+
},
|
|
241
|
+
h(
|
|
242
|
+
'box',
|
|
243
|
+
{
|
|
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,
|
|
253
|
+
},
|
|
254
|
+
h(
|
|
255
|
+
'scrollview',
|
|
256
|
+
{ ref: scrollRef, flexGrow: 1, padding: 4 },
|
|
257
|
+
normalized.map((option, index) =>
|
|
258
|
+
h(Option, {
|
|
259
|
+
key: String(option.value),
|
|
260
|
+
option,
|
|
261
|
+
selected: option.value === value,
|
|
262
|
+
active: index === activeIndex,
|
|
263
|
+
nodeRef: index === activeIndex ? activeRef : undefined,
|
|
264
|
+
onHover: () => setActiveIndex(index),
|
|
265
|
+
onPick: pick,
|
|
266
|
+
}),
|
|
267
|
+
),
|
|
268
|
+
),
|
|
269
|
+
),
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
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, { useRef, useState } from 'react';
|
|
6
|
+
import { useTheme } from './theme.js';
|
|
7
|
+
import {
|
|
8
|
+
XK_DOWN,
|
|
9
|
+
XK_END,
|
|
10
|
+
XK_HOME,
|
|
11
|
+
XK_LEFT,
|
|
12
|
+
XK_PAGE_DOWN,
|
|
13
|
+
XK_PAGE_UP,
|
|
14
|
+
XK_RIGHT,
|
|
15
|
+
XK_UP,
|
|
16
|
+
} from './keys.js';
|
|
17
|
+
|
|
18
|
+
const h = React.createElement;
|
|
19
|
+
|
|
20
|
+
const SLIDER_THUMB = 16;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* <Slider value min max step onChange disabled …boxProps> — draggable
|
|
24
|
+
* value control.
|
|
25
|
+
*
|
|
26
|
+
* Dragging uses pointer capture (`ev.capturePointer()`): once the press
|
|
27
|
+
* lands, move and up events keep coming to the track even when the pointer
|
|
28
|
+
* leaves it, so the thumb still tracks a pointer that has wandered far
|
|
29
|
+
* outside the widget — and releasing out there still ends the drag.
|
|
30
|
+
*
|
|
31
|
+
* Keyboard: arrows step, Home/End jump to the ends, PageUp/PageDown move
|
|
32
|
+
* by ten steps.
|
|
33
|
+
*/
|
|
34
|
+
export function Slider({
|
|
35
|
+
value = 0,
|
|
36
|
+
min = 0,
|
|
37
|
+
max = 100,
|
|
38
|
+
step = 1,
|
|
39
|
+
onChange,
|
|
40
|
+
disabled = false,
|
|
41
|
+
width,
|
|
42
|
+
height = 4,
|
|
43
|
+
...boxProps
|
|
44
|
+
}) {
|
|
45
|
+
const theme = useTheme();
|
|
46
|
+
const [focused, setFocused] = useState(false);
|
|
47
|
+
const [dragging, setDragging] = useState(false);
|
|
48
|
+
const trackRef = useRef(null);
|
|
49
|
+
|
|
50
|
+
const span = max - min || 1;
|
|
51
|
+
const clamp = (v) => Math.min(max, Math.max(min, v));
|
|
52
|
+
const quantize = (v) => {
|
|
53
|
+
if (!step) return clamp(v);
|
|
54
|
+
// round to the step grid measured from min, then trim float noise so
|
|
55
|
+
// 0.1-style steps do not accumulate 0.30000000000000004
|
|
56
|
+
const snapped = min + Math.round((v - min) / step) * step;
|
|
57
|
+
const decimals = (String(step).split('.')[1] ?? '').length;
|
|
58
|
+
return clamp(decimals ? Number(snapped.toFixed(decimals)) : snapped);
|
|
59
|
+
};
|
|
60
|
+
const fraction = (clamp(value) - min) / span;
|
|
61
|
+
|
|
62
|
+
const emit = (next) => {
|
|
63
|
+
if (next !== value) onChange?.(next);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Pointer x -> value, using the track's laid-out rect. */
|
|
67
|
+
const valueAt = (ev) => {
|
|
68
|
+
const node = trackRef.current;
|
|
69
|
+
if (!node?.abs?.width) return value;
|
|
70
|
+
// the thumb is centred on the value, so the usable travel is the track
|
|
71
|
+
// minus one thumb width — otherwise min/max are unreachable at the ends
|
|
72
|
+
const travel = Math.max(1, node.abs.width - SLIDER_THUMB);
|
|
73
|
+
const x = ev.x - node.abs.x - SLIDER_THUMB / 2;
|
|
74
|
+
return quantize(min + (Math.min(travel, Math.max(0, x)) / travel) * span);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const controlProps = disabled
|
|
78
|
+
? {}
|
|
79
|
+
: {
|
|
80
|
+
focusable: true,
|
|
81
|
+
cursor: 'pointer',
|
|
82
|
+
onFocus: () => setFocused(true),
|
|
83
|
+
onBlur: () => setFocused(false),
|
|
84
|
+
onMouseDown: (ev) => {
|
|
85
|
+
ev.capturePointer();
|
|
86
|
+
setDragging(true);
|
|
87
|
+
emit(valueAt(ev));
|
|
88
|
+
},
|
|
89
|
+
onMouseMove: (ev) => {
|
|
90
|
+
if (dragging) emit(valueAt(ev));
|
|
91
|
+
},
|
|
92
|
+
onMouseUp: () => setDragging(false),
|
|
93
|
+
onKeyDown: (ev) => {
|
|
94
|
+
const big = (step || 1) * 10;
|
|
95
|
+
switch (ev.keysym) {
|
|
96
|
+
case XK_LEFT:
|
|
97
|
+
case XK_DOWN:
|
|
98
|
+
emit(quantize(clamp(value - (step || 1))));
|
|
99
|
+
return;
|
|
100
|
+
case XK_RIGHT:
|
|
101
|
+
case XK_UP:
|
|
102
|
+
emit(quantize(clamp(value + (step || 1))));
|
|
103
|
+
return;
|
|
104
|
+
case XK_HOME:
|
|
105
|
+
emit(min);
|
|
106
|
+
return;
|
|
107
|
+
case XK_END:
|
|
108
|
+
emit(max);
|
|
109
|
+
return;
|
|
110
|
+
case XK_PAGE_UP:
|
|
111
|
+
emit(quantize(clamp(value + big)));
|
|
112
|
+
return;
|
|
113
|
+
case XK_PAGE_DOWN:
|
|
114
|
+
emit(quantize(clamp(value - big)));
|
|
115
|
+
return;
|
|
116
|
+
default:
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return h(
|
|
123
|
+
'box',
|
|
124
|
+
{
|
|
125
|
+
ref: trackRef,
|
|
126
|
+
width,
|
|
127
|
+
height: SLIDER_THUMB,
|
|
128
|
+
minWidth: 0,
|
|
129
|
+
justifyContent: 'center',
|
|
130
|
+
...controlProps,
|
|
131
|
+
...boxProps,
|
|
132
|
+
},
|
|
133
|
+
// track
|
|
134
|
+
h(
|
|
135
|
+
'box',
|
|
136
|
+
{
|
|
137
|
+
height,
|
|
138
|
+
borderRadius: height / 2,
|
|
139
|
+
backgroundColor: theme.track,
|
|
140
|
+
flexDirection: 'row',
|
|
141
|
+
alignItems: 'center',
|
|
142
|
+
pointerEvents: 'none',
|
|
143
|
+
},
|
|
144
|
+
// flex ratios, not a percentage width: a percentage child resolves
|
|
145
|
+
// against the space available while the track is still being
|
|
146
|
+
// measured, so it fed back into the slider's own intrinsic width —
|
|
147
|
+
// at value = max the control grew, which moved the handle, which
|
|
148
|
+
// changed the value, and a drag turned into an oscillation
|
|
149
|
+
h('box', {
|
|
150
|
+
flexGrow: fraction,
|
|
151
|
+
flexShrink: 0,
|
|
152
|
+
flexBasis: 0,
|
|
153
|
+
height,
|
|
154
|
+
borderRadius: height / 2,
|
|
155
|
+
backgroundColor: disabled ? theme.dim : theme.accent,
|
|
156
|
+
}),
|
|
157
|
+
h('box', { flexGrow: 1 - fraction, flexShrink: 0, flexBasis: 0 }),
|
|
158
|
+
),
|
|
159
|
+
// thumb, centred on the value within the same travel the math uses
|
|
160
|
+
h('box', {
|
|
161
|
+
position: 'absolute',
|
|
162
|
+
left: `${fraction * 100}%`,
|
|
163
|
+
marginLeft: -SLIDER_THUMB * fraction,
|
|
164
|
+
width: SLIDER_THUMB,
|
|
165
|
+
height: SLIDER_THUMB,
|
|
166
|
+
borderRadius: SLIDER_THUMB / 2,
|
|
167
|
+
borderWidth: 1,
|
|
168
|
+
borderColor: disabled
|
|
169
|
+
? theme.border
|
|
170
|
+
: focused || dragging
|
|
171
|
+
? theme.accentHover
|
|
172
|
+
: theme.border,
|
|
173
|
+
backgroundColor: disabled ? theme.surfaceHover : theme.background,
|
|
174
|
+
pointerEvents: 'none',
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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 from 'react';
|
|
6
|
+
import { useControl, useTheme } from './theme.js';
|
|
7
|
+
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* <Switch checked onChange disabled/> — Checkbox semantics in a sliding
|
|
12
|
+
* pill; the thumb sits at the end matching the state.
|
|
13
|
+
*/
|
|
14
|
+
export function Switch({
|
|
15
|
+
checked = false,
|
|
16
|
+
onChange,
|
|
17
|
+
disabled = false,
|
|
18
|
+
...boxProps
|
|
19
|
+
}) {
|
|
20
|
+
const theme = useTheme();
|
|
21
|
+
const { focused, props } = useControl(disabled, () => onChange?.(!checked));
|
|
22
|
+
return h(
|
|
23
|
+
'box',
|
|
24
|
+
{
|
|
25
|
+
width: 36,
|
|
26
|
+
height: 20,
|
|
27
|
+
borderRadius: 10,
|
|
28
|
+
padding: 2,
|
|
29
|
+
flexDirection: 'row',
|
|
30
|
+
alignItems: 'center',
|
|
31
|
+
justifyContent: checked ? 'flex-end' : 'flex-start',
|
|
32
|
+
backgroundColor: disabled
|
|
33
|
+
? theme.track
|
|
34
|
+
: checked
|
|
35
|
+
? theme.accent
|
|
36
|
+
: focused
|
|
37
|
+
? theme.dim
|
|
38
|
+
: theme.border,
|
|
39
|
+
...props,
|
|
40
|
+
...boxProps,
|
|
41
|
+
},
|
|
42
|
+
h('box', {
|
|
43
|
+
width: 16,
|
|
44
|
+
height: 16,
|
|
45
|
+
borderRadius: 8,
|
|
46
|
+
backgroundColor: theme.background,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
}
|