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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// ClientMessage, delivered to the element it was addressed to.
|
|
2
|
+
//
|
|
3
|
+
// ClientMessage is the carrier of every convention layered over the core
|
|
4
|
+
// protocol: EWMH's requests to the window manager, ICCCM's WM_PROTOCOLS,
|
|
5
|
+
// XEmbed, XDND, the system tray, and whatever two copies of one application
|
|
6
|
+
// agree between themselves. react-x11 speaks a few of those itself — XDND in
|
|
7
|
+
// `src/dnd.js`, WM_DELETE_WINDOW through ntk's `close` — and an application
|
|
8
|
+
// that speaks one core does not had exactly one route: subscribe to
|
|
9
|
+
// `X.on('event')`, which is every event on the connection for every window,
|
|
10
|
+
// and filter. `src/xsettings.js` does that internally and it is the right
|
|
11
|
+
// shape *there*, because a settings daemon's window is nobody's element. It
|
|
12
|
+
// is the wrong shape to hand an application: it is not scoped to a window,
|
|
13
|
+
// it does not go away when the window unmounts, and it is expressed in atom
|
|
14
|
+
// ids rather than names.
|
|
15
|
+
//
|
|
16
|
+
// So `<window onClientMessage>` is the seam, and this is what fills it.
|
|
17
|
+
//
|
|
18
|
+
// ## The type is a name, and that costs a round trip once
|
|
19
|
+
//
|
|
20
|
+
// `message_type` is an atom, and an atom is a number that means nothing away
|
|
21
|
+
// from the server that issued it. Comparing against one means interning it
|
|
22
|
+
// first, which is asynchronous, so the obvious handler cannot be written as a
|
|
23
|
+
// `switch` — it has to wait for an atom table to arrive before it can tell
|
|
24
|
+
// one message from another, and that table is the boilerplate this seam
|
|
25
|
+
// exists to delete.
|
|
26
|
+
//
|
|
27
|
+
// Most of the time the name is already there: node-x11 keeps a per-connection
|
|
28
|
+
// id → name table filled from every InternAtom and GetAtomName reply, so an
|
|
29
|
+
// atom this application has ever *named* — which is every atom in a protocol
|
|
30
|
+
// it sends, advertises or owns anything for — resolves synchronously and the
|
|
31
|
+
// message is dispatched in the turn it arrived in.
|
|
32
|
+
//
|
|
33
|
+
// A protocol this application only ever *receives* has no such moment, and
|
|
34
|
+
// that case is the whole point of the feature: a tray host does not send
|
|
35
|
+
// `_NET_SYSTEM_TRAY_OPCODE`, it is sent one. So an unknown atom is resolved
|
|
36
|
+
// with `GetAtomName` — and **every message behind it waits**, which is the
|
|
37
|
+
// part that is not optional. The protocols carried this way are chunked
|
|
38
|
+
// (`_NET_SYSTEM_TRAY_BEGIN_MESSAGE` and the `_NET_SYSTEM_TRAY_MESSAGE_DATA`
|
|
39
|
+
// pieces that reassemble by arrival order alone) or sequenced (XEmbed), so a
|
|
40
|
+
// round trip that let a later message overtake an earlier one would corrupt
|
|
41
|
+
// them in a way no handler could detect. The same FIFO gate `src/dnd.js`
|
|
42
|
+
// runs its own messages through, for the same reason.
|
|
43
|
+
//
|
|
44
|
+
// The cost is one round trip per message *type* per connection — node-x11
|
|
45
|
+
// caches the reply, so the second `_NET_SYSTEM_TRAY_OPCODE` is synchronous
|
|
46
|
+
// like everything else. `messageType` is therefore null only for an atom the
|
|
47
|
+
// server itself does not know, which is a broken sender rather than a case to
|
|
48
|
+
// design around; `atom` carries the id regardless.
|
|
49
|
+
|
|
50
|
+
/** X's event code for ClientMessage, for `ev.type`. */
|
|
51
|
+
const CLIENT_MESSAGE = 33;
|
|
52
|
+
|
|
53
|
+
/** Per-connection id → name lookups in flight or resolved, negatives kept:
|
|
54
|
+
* a sender repeating a bogus atom must not repeat the round trip. */
|
|
55
|
+
const nameCaches = new WeakMap();
|
|
56
|
+
|
|
57
|
+
/** The name of an atom, or a promise for it. Never rejects. */
|
|
58
|
+
function atomName(X, id) {
|
|
59
|
+
const known = X?.atom_names?.[id];
|
|
60
|
+
if (known !== undefined) return known;
|
|
61
|
+
if (typeof X?.GetAtomName !== 'function') return null;
|
|
62
|
+
let cache = nameCaches.get(X);
|
|
63
|
+
if (!cache) nameCaches.set(X, (cache = new Map()));
|
|
64
|
+
const hit = cache.get(id);
|
|
65
|
+
if (hit) return hit;
|
|
66
|
+
const pending = new Promise((resolve) =>
|
|
67
|
+
X.GetAtomName(id, (err, name) => resolve(err ? null : name)),
|
|
68
|
+
);
|
|
69
|
+
cache.set(id, pending);
|
|
70
|
+
return pending;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The stream of ClientMessages for one window: names each message's type and
|
|
75
|
+
* hands it to `dispatch` in arrival order.
|
|
76
|
+
*
|
|
77
|
+
* `dispatch` is the caller's, so the priority and the paint stay with the
|
|
78
|
+
* other window events in nodes.js; what lives here is the naming and the
|
|
79
|
+
* ordering it has to preserve.
|
|
80
|
+
*/
|
|
81
|
+
export function createClientMessages(node, dispatch) {
|
|
82
|
+
let queued = 0;
|
|
83
|
+
let chain = Promise.resolve();
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The event a handler receives. Not a `SyntheticEvent`: a ClientMessage is
|
|
87
|
+
* addressed to a *window*, so there is no node under it, nothing to hit
|
|
88
|
+
* test and no chain to bubble along. Same shape `onResize` has, for the
|
|
89
|
+
* same reason.
|
|
90
|
+
*/
|
|
91
|
+
const build = (raw, messageType) => ({
|
|
92
|
+
type: CLIENT_MESSAGE,
|
|
93
|
+
messageType,
|
|
94
|
+
atom: raw.message_type,
|
|
95
|
+
format: raw.format,
|
|
96
|
+
data: raw.data,
|
|
97
|
+
window: node.window,
|
|
98
|
+
target: node.window,
|
|
99
|
+
nativeEvent: raw,
|
|
100
|
+
get defaultPrevented() {
|
|
101
|
+
return raw.defaultPrevented === true;
|
|
102
|
+
},
|
|
103
|
+
preventDefault() {
|
|
104
|
+
// Marked on the raw event, because what reads it is a second
|
|
105
|
+
// subscriber to the same ntk stream (`WindowNode._initDnd`) rather
|
|
106
|
+
// than a later step of this dispatch.
|
|
107
|
+
raw.defaultPrevented = true;
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
/** Take one raw ntk `'message'` event. */
|
|
113
|
+
handle(raw) {
|
|
114
|
+
const name = atomName(node.app?.X, raw.message_type);
|
|
115
|
+
const settled = name === null || typeof name === 'string';
|
|
116
|
+
if (queued === 0 && settled) {
|
|
117
|
+
dispatch(build(raw, name));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
queued++;
|
|
121
|
+
chain = chain
|
|
122
|
+
.then(() => name)
|
|
123
|
+
.then((resolved) => dispatch(build(raw, resolved)))
|
|
124
|
+
.catch(() => {}) // a handler throw is already reported by callHandler
|
|
125
|
+
.then(() => {
|
|
126
|
+
queued--;
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `null` when every message so far has been dispatched, so a default
|
|
132
|
+
* action can run in the same turn its message arrived in; otherwise the
|
|
133
|
+
* promise after which it has been — which is what lets `preventDefault()`
|
|
134
|
+
* still reach XDND on the one message whose type had to be named first.
|
|
135
|
+
*/
|
|
136
|
+
pending() {
|
|
137
|
+
return queued === 0 ? null : chain;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/clipboard.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The clipboard an application sees.
|
|
2
|
+
//
|
|
3
|
+
// ntk's `app.clipboard` is the ICCCM selection machinery: it owns
|
|
4
|
+
// selections, answers conversions, and speaks INCR. This is the layer above
|
|
5
|
+
// it, and it adds exactly three things, none of which belong upstream:
|
|
6
|
+
//
|
|
7
|
+
// 1. the type vocabulary (transfer.js) — so `read('text')` finds GTK's
|
|
8
|
+
// UTF8_STRING and `readFiles()` parses a file manager's uri-list,
|
|
9
|
+
// instead of every app rediscovering the same table;
|
|
10
|
+
// 2. an ICCCM timestamp by default (inputtime.js), so a copy is stamped
|
|
11
|
+
// with the keystroke that caused it rather than with "now";
|
|
12
|
+
// 3. a way in from React (useClipboard), since a component has no route
|
|
13
|
+
// to the connection.
|
|
14
|
+
//
|
|
15
|
+
// Everything on the wire is still ntk's. `root.app.clipboard` remains the
|
|
16
|
+
// documented escape hatch for anything this does not cover.
|
|
17
|
+
|
|
18
|
+
import { lastInputTime } from './inputtime.js';
|
|
19
|
+
import { decodeData, parseUriList, resolveType } from './transfer.js';
|
|
20
|
+
|
|
21
|
+
/** Ask the owner what it has, so a group name can be resolved against a
|
|
22
|
+
* real offer rather than guessed at one failed conversion per guess. */
|
|
23
|
+
async function offeredTypes(clipboard, options) {
|
|
24
|
+
try {
|
|
25
|
+
return await clipboard.targets(options);
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The object `useClipboard()` hands back. One per app — it holds no state
|
|
33
|
+
* of its own, so making a second is harmless.
|
|
34
|
+
*/
|
|
35
|
+
export function createClipboard(app) {
|
|
36
|
+
const raw = () => {
|
|
37
|
+
const c = app?.clipboard;
|
|
38
|
+
if (!c) throw new Error('react-x11: this app has no clipboard');
|
|
39
|
+
return c;
|
|
40
|
+
};
|
|
41
|
+
// every call takes the same three, and every one of them has a default
|
|
42
|
+
// worth not repeating
|
|
43
|
+
const opts = ({ selection = 'CLIPBOARD', timeout, time } = {}) => ({
|
|
44
|
+
selection,
|
|
45
|
+
...(timeout === undefined ? {} : { timeout }),
|
|
46
|
+
time: time === undefined ? lastInputTime(app) : time,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
/** Take a selection and serve `data` — a string, or a map of type name
|
|
51
|
+
* to string/bytes for offering several flavours of one thing. */
|
|
52
|
+
write(data, options) {
|
|
53
|
+
return raw().write(data, opts(options));
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
/** Shorthand for the common case; offered as UTF8_STRING and STRING. */
|
|
57
|
+
writeText(text, options) {
|
|
58
|
+
return raw().write(String(text), opts(options));
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
/** Give the selection back, so nothing is served for it any more. */
|
|
62
|
+
clear(selection = 'CLIPBOARD') {
|
|
63
|
+
return raw().clear(selection);
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/** What the current owner can convert to. `[]` when nothing owns it. */
|
|
67
|
+
targets(options) {
|
|
68
|
+
return raw().targets(opts(options));
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/** The plain text of a selection, ntk's UTF8_STRING → STRING walk.
|
|
72
|
+
* `read('text')` is the interop-hardened version of this. */
|
|
73
|
+
readText(options) {
|
|
74
|
+
return raw().read(opts(options));
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One type, decoded: a concrete name (`'image/png'`) or a group
|
|
79
|
+
* (`'text'`, `'files'`, `'uris'`) resolved against what the owner
|
|
80
|
+
* actually offers. Text-ish types come back as a string, everything
|
|
81
|
+
* else as bytes. `null` when the owner has nothing of that kind —
|
|
82
|
+
* which is a question, not an error, unlike `readText()` on an empty
|
|
83
|
+
* clipboard.
|
|
84
|
+
*/
|
|
85
|
+
async read(type, options) {
|
|
86
|
+
const o = opts(options);
|
|
87
|
+
const offered = await offeredTypes(raw(), o);
|
|
88
|
+
const target = resolveType(type, offered);
|
|
89
|
+
if (!offered.includes(target)) return null;
|
|
90
|
+
const data = await raw().read({ ...o, target });
|
|
91
|
+
return typeof data === 'string' ? data : decodeData(data, target);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Files copied in a file manager, parsed (RFC 2483). `[]` when the
|
|
96
|
+
* clipboard holds no file flavour, so a paste handler can call it
|
|
97
|
+
* without asking first.
|
|
98
|
+
*/
|
|
99
|
+
async readFiles(options) {
|
|
100
|
+
const list = await this.read('files', options);
|
|
101
|
+
return typeof list === 'string' ? parseUriList(list) : [];
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Call `handler` whenever the selection changes hands — an XFixes
|
|
106
|
+
* subscription, not a poll. `ev.owner === 0` means nothing is on the
|
|
107
|
+
* clipboard, which is the case an edit menu wants.
|
|
108
|
+
*
|
|
109
|
+
* Rejects on a server without XFixes; every X server since about 2004
|
|
110
|
+
* has it, so treat that as "this server is unusual" rather than as a
|
|
111
|
+
* path to code around.
|
|
112
|
+
*/
|
|
113
|
+
watch(selectionOrHandler, maybeHandler) {
|
|
114
|
+
const [selection, handler] =
|
|
115
|
+
typeof selectionOrHandler === 'function'
|
|
116
|
+
? ['CLIPBOARD', selectionOrHandler]
|
|
117
|
+
: [selectionOrHandler, maybeHandler];
|
|
118
|
+
return raw().watch(selection, handler);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
package/src/components/Button.js
CHANGED
|
@@ -7,57 +7,172 @@ import { labelContent, useControl, useTheme } from './theme.js';
|
|
|
7
7
|
|
|
8
8
|
const h = React.createElement;
|
|
9
9
|
|
|
10
|
+
const VARIANTS = ['solid', 'outline', 'ghost'];
|
|
11
|
+
const SIZES = ['medium', 'small'];
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
|
-
* <Button onPress primary disabled …boxProps>label</Button> —
|
|
12
|
-
* push button the examples kept re-implementing: hover/
|
|
13
|
-
* Space/Enter activation, pointer cursor.
|
|
14
|
+
* <Button onPress variant size primary disabled …boxProps>label</Button> —
|
|
15
|
+
* the standard push button the examples kept re-implementing: hover/press/
|
|
16
|
+
* focus feedback, Space/Enter activation, pointer cursor.
|
|
17
|
+
*
|
|
18
|
+
* Two axes rather than one list of looks. `variant` is how much chrome the
|
|
19
|
+
* button carries — `solid` a fill, `outline` a border on nothing, `ghost`
|
|
20
|
+
* neither, for the affordance that sits *inside* other content (a `✕` on a
|
|
21
|
+
* chip, a jump arrow beside a value) and must not add a box to the row it
|
|
22
|
+
* lives in. `primary` is whose colours it speaks in: the accent as the fill
|
|
23
|
+
* when there is one, the accent as ink and border when there is not. The
|
|
24
|
+
* axes compose, so a dialog footer's secondary action is `variant="outline"`
|
|
25
|
+
* next to a `primary` solid, and a toolbar's loudest icon is
|
|
26
|
+
* `primary variant="ghost"` — one component, one set of states.
|
|
27
|
+
*
|
|
28
|
+
* `size="small"` is the compact metric a toolbar or an inline row wants:
|
|
29
|
+
* half the control padding, everything else in proportion. In the component
|
|
30
|
+
* rather than in a `style` because the padding is derived from the palette —
|
|
31
|
+
* a theme that moves `paddingY` moves both sizes together, where a hand-made
|
|
32
|
+
* `{ height: 22 }` would be left behind.
|
|
33
|
+
*
|
|
34
|
+
* `onPress` fires on the **release**, as a click does everywhere — so the
|
|
35
|
+
* button darkens on the press instead, and keeps the darker fill for as long
|
|
36
|
+
* as the button is held. Without that the whole of a slow click is a control
|
|
37
|
+
* that has not answered: press, nothing, nothing, and then the action. The
|
|
38
|
+
* press state is what makes an unhurried click feel immediate, and it costs
|
|
39
|
+
* one node's repaint because it is a style block rather than React state.
|
|
14
40
|
*/
|
|
15
41
|
export function Button({
|
|
16
42
|
children,
|
|
17
43
|
label,
|
|
18
44
|
onPress,
|
|
19
45
|
primary = false,
|
|
46
|
+
variant = 'solid',
|
|
47
|
+
size = 'medium',
|
|
20
48
|
disabled = false,
|
|
49
|
+
style,
|
|
21
50
|
...boxProps
|
|
22
51
|
}) {
|
|
52
|
+
// Before the hook on purpose, as `<Icon name>` does it: an unknown value
|
|
53
|
+
// never renders, so the message lands on the call site — where TypeScript
|
|
54
|
+
// is not there to catch the typo, silence would be an outline button.
|
|
55
|
+
if (!VARIANTS.includes(variant)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`<Button variant="${variant}">: one of ${VARIANTS.join(', ')}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (!SIZES.includes(size)) {
|
|
61
|
+
throw new Error(`<Button size="${size}">: one of ${SIZES.join(', ')}`);
|
|
62
|
+
}
|
|
23
63
|
const theme = useTheme();
|
|
24
|
-
const {
|
|
25
|
-
|
|
26
|
-
|
|
64
|
+
const { props, style: controlStyle } = useControl(disabled, onPress, {
|
|
65
|
+
styled: true,
|
|
66
|
+
});
|
|
67
|
+
const solid = variant === 'solid';
|
|
68
|
+
const ghost = variant === 'ghost';
|
|
69
|
+
const small = size === 'small';
|
|
70
|
+
const background = !solid
|
|
71
|
+
? // `transparent` rather than the ground's colour: an outline or ghost
|
|
72
|
+
// button sits on whatever it sits on — a toolbar, a card, a table row —
|
|
73
|
+
// and naming a fill would give it a box on any ground but one
|
|
74
|
+
'transparent'
|
|
75
|
+
: disabled
|
|
76
|
+
? theme.surfaceHover
|
|
77
|
+
: primary
|
|
78
|
+
? theme.accent
|
|
79
|
+
: theme.surface;
|
|
80
|
+
const color = disabled
|
|
81
|
+
? theme.textMuted
|
|
27
82
|
: primary
|
|
28
|
-
?
|
|
29
|
-
? theme.
|
|
83
|
+
? solid
|
|
84
|
+
? theme.accentText
|
|
30
85
|
: theme.accent
|
|
31
|
-
:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
86
|
+
: theme.text;
|
|
87
|
+
// A ghost button keeps the border *width* and loses only the colour, so
|
|
88
|
+
// every variant is the same sum and a mixed row lines up — a field padded
|
|
89
|
+
// with `$paddingY` is exactly this tall too (docs/styling.md).
|
|
90
|
+
const borderColor = ghost
|
|
91
|
+
? 'transparent'
|
|
92
|
+
: disabled || !primary
|
|
93
|
+
? theme.border
|
|
94
|
+
: theme.accent;
|
|
35
95
|
return h(
|
|
36
96
|
'box',
|
|
37
97
|
{
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
justifyContent: 'center',
|
|
41
|
-
gap: 8,
|
|
42
|
-
paddingTop: 8,
|
|
43
|
-
paddingBottom: 8,
|
|
44
|
-
paddingLeft: 16,
|
|
45
|
-
paddingRight: 16,
|
|
46
|
-
borderRadius: 4,
|
|
47
|
-
borderWidth: 1,
|
|
48
|
-
borderColor: disabled
|
|
49
|
-
? theme.border
|
|
50
|
-
: focused
|
|
51
|
-
? primary
|
|
52
|
-
? theme.accentHover
|
|
53
|
-
: theme.borderActive
|
|
54
|
-
: primary
|
|
55
|
-
? theme.accent
|
|
56
|
-
: theme.border,
|
|
57
|
-
backgroundColor: background,
|
|
98
|
+
theme,
|
|
99
|
+
role: 'button',
|
|
58
100
|
...props,
|
|
59
101
|
...boxProps,
|
|
102
|
+
style: [
|
|
103
|
+
controlStyle,
|
|
104
|
+
{
|
|
105
|
+
flexDirection: 'row',
|
|
106
|
+
alignItems: 'center',
|
|
107
|
+
justifyContent: 'center',
|
|
108
|
+
gap: small ? 6 : 8,
|
|
109
|
+
paddingTop: small ? Math.round(theme.paddingY / 2) : theme.paddingY,
|
|
110
|
+
paddingBottom: small
|
|
111
|
+
? Math.round(theme.paddingY / 2)
|
|
112
|
+
: theme.paddingY,
|
|
113
|
+
paddingLeft: small ? Math.round(theme.paddingX / 2) : theme.paddingX,
|
|
114
|
+
paddingRight: small ? Math.round(theme.paddingX / 2) : theme.paddingX,
|
|
115
|
+
borderRadius: small ? theme.radiusSmall : theme.radius,
|
|
116
|
+
borderWidth: theme.borderWidth,
|
|
117
|
+
borderColor,
|
|
118
|
+
backgroundColor: background,
|
|
119
|
+
// The label ink goes on the box, not on the label: `color` is
|
|
120
|
+
// inherited (docs/styling.md), so an element child — an <Icon>,
|
|
121
|
+
// a <text> — takes the same answer a string child always got,
|
|
122
|
+
// and an icon+label button dims as one thing when disabled.
|
|
123
|
+
color,
|
|
124
|
+
},
|
|
125
|
+
// All three as state blocks: a repaint of one node each, where React
|
|
126
|
+
// state re-rendered the button and its label to change a colour.
|
|
127
|
+
// `:focus-visible` rather than `:focus` is the difference between
|
|
128
|
+
// "you clicked here" and "your keyboard is here", and only the
|
|
129
|
+
// second is worth a ring.
|
|
130
|
+
!disabled &&
|
|
131
|
+
(solid
|
|
132
|
+
? {
|
|
133
|
+
':hover': {
|
|
134
|
+
backgroundColor: primary
|
|
135
|
+
? theme.accentHover
|
|
136
|
+
: theme.surfaceHover,
|
|
137
|
+
borderColor: primary ? theme.accentHover : theme.border,
|
|
138
|
+
},
|
|
139
|
+
// the border follows the fill: a dark pressed face inside a
|
|
140
|
+
// resting-coloured ring reads as a rendering bug, not a press
|
|
141
|
+
':active': {
|
|
142
|
+
backgroundColor: primary
|
|
143
|
+
? theme.accentActive
|
|
144
|
+
: theme.surfaceActive,
|
|
145
|
+
borderColor: primary ? theme.accentActive : theme.textMuted,
|
|
146
|
+
},
|
|
147
|
+
':focus-visible': {
|
|
148
|
+
borderColor: primary ? theme.accentHover : theme.borderFocus,
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
: {
|
|
152
|
+
// Outline and ghost answer the pointer with the neutral wash
|
|
153
|
+
// whichever colours they speak in: the surface steps are the
|
|
154
|
+
// hover-and-press ramp for anything without an accent fill,
|
|
155
|
+
// and the background paints under the border band, so a ghost
|
|
156
|
+
// button's wash covers the full box.
|
|
157
|
+
':hover': { backgroundColor: theme.surfaceHover },
|
|
158
|
+
':active': {
|
|
159
|
+
backgroundColor: theme.surfaceActive,
|
|
160
|
+
...(ghost
|
|
161
|
+
? null
|
|
162
|
+
: {
|
|
163
|
+
borderColor: primary
|
|
164
|
+
? theme.accentActive
|
|
165
|
+
: theme.textMuted,
|
|
166
|
+
}),
|
|
167
|
+
},
|
|
168
|
+
// …which on a ghost button makes the border appear, and that
|
|
169
|
+
// is right: the keyboard has no hover, so the ring is how a
|
|
170
|
+
// chrome-less control says "your keyboard is here".
|
|
171
|
+
':focus-visible': { borderColor: theme.borderFocus },
|
|
172
|
+
}),
|
|
173
|
+
style,
|
|
174
|
+
],
|
|
60
175
|
},
|
|
61
|
-
labelContent(children ?? label
|
|
176
|
+
labelContent(children ?? label),
|
|
62
177
|
);
|
|
63
178
|
}
|
|
@@ -3,67 +3,101 @@
|
|
|
3
3
|
// build-step-free for consumers.
|
|
4
4
|
|
|
5
5
|
import React from 'react';
|
|
6
|
+
import { changeEvent } from './change.js';
|
|
7
|
+
import { Icon } from './Icon.js';
|
|
6
8
|
import { labelContent, useControl, useTheme } from './theme.js';
|
|
7
9
|
|
|
8
10
|
const h = React.createElement;
|
|
9
11
|
|
|
12
|
+
/** The mark inside the 16px well. */
|
|
13
|
+
const MARK = 11;
|
|
14
|
+
|
|
10
15
|
/**
|
|
11
16
|
* <Checkbox checked onChange disabled>label</Checkbox> — 16px check well +
|
|
12
|
-
* label row; click or Space toggles (
|
|
17
|
+
* label row; click or Space toggles. `onChange(ev)` gets a change event, the
|
|
18
|
+
* same shape `<textinput>` fires: the next value is `ev.value`, and
|
|
19
|
+
* `ev.target` carries `name`/`checked` for a form library.
|
|
20
|
+
*
|
|
21
|
+
* The tick only appears on the release, so the well takes a pressed fill on
|
|
22
|
+
* the press to cover the gap. This is the case `useControl` keeps in React
|
|
23
|
+
* state rather than a style block: the press lands anywhere along the row —
|
|
24
|
+
* usually the label — and `:active` marks that node and its ancestors, which
|
|
25
|
+
* the well is not. One render per press, for a control whose next act is to
|
|
26
|
+
* re-render with a new `checked` anyway.
|
|
13
27
|
*/
|
|
14
28
|
export function Checkbox({
|
|
15
29
|
children,
|
|
16
30
|
label,
|
|
17
31
|
checked = false,
|
|
18
32
|
onChange,
|
|
33
|
+
name,
|
|
19
34
|
disabled = false,
|
|
35
|
+
style,
|
|
20
36
|
...boxProps
|
|
21
37
|
}) {
|
|
22
38
|
const theme = useTheme();
|
|
23
|
-
const {
|
|
24
|
-
|
|
39
|
+
const {
|
|
40
|
+
hover,
|
|
41
|
+
focused,
|
|
42
|
+
pressed,
|
|
43
|
+
props,
|
|
44
|
+
style: controlStyle,
|
|
45
|
+
} = useControl(disabled, () =>
|
|
46
|
+
onChange?.(changeEvent('checkbox', name, !checked)),
|
|
47
|
+
);
|
|
48
|
+
const fill = disabled
|
|
49
|
+
? theme.textMuted
|
|
50
|
+
: pressed
|
|
51
|
+
? theme.accentActive
|
|
52
|
+
: hover
|
|
53
|
+
? theme.accentHover
|
|
54
|
+
: theme.accent;
|
|
55
|
+
// An empty well has no fill to step, so it shows the two states in two
|
|
56
|
+
// different places: hovering firms the ring up, and pressing greys the
|
|
57
|
+
// inside as well. Three looks, which is the point — a hover the press
|
|
58
|
+
// cannot be told apart from says nothing about the press.
|
|
59
|
+
const empty = {
|
|
60
|
+
borderColor:
|
|
61
|
+
pressed || hover
|
|
62
|
+
? theme.textMuted
|
|
63
|
+
: focused
|
|
64
|
+
? theme.borderFocus
|
|
65
|
+
: theme.border,
|
|
66
|
+
backgroundColor: pressed ? theme.surfaceActive : theme.surface,
|
|
67
|
+
};
|
|
25
68
|
return h(
|
|
26
69
|
'box',
|
|
27
70
|
{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
71
|
+
theme,
|
|
72
|
+
role: 'checkbox',
|
|
73
|
+
'aria-checked': checked,
|
|
31
74
|
...props,
|
|
32
75
|
...boxProps,
|
|
76
|
+
style: [
|
|
77
|
+
controlStyle,
|
|
78
|
+
{ flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
79
|
+
style,
|
|
80
|
+
],
|
|
33
81
|
},
|
|
34
82
|
h(
|
|
35
83
|
'box',
|
|
36
84
|
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
? fill
|
|
43
|
-
:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
alignItems: 'center',
|
|
48
|
-
justifyContent: 'center',
|
|
85
|
+
style: {
|
|
86
|
+
width: 16,
|
|
87
|
+
height: 16,
|
|
88
|
+
borderRadius: theme.radiusSmall,
|
|
89
|
+
borderWidth: theme.borderWidth,
|
|
90
|
+
borderColor: checked ? fill : empty.borderColor,
|
|
91
|
+
backgroundColor: checked ? fill : empty.backgroundColor,
|
|
92
|
+
alignItems: 'center',
|
|
93
|
+
justifyContent: 'center',
|
|
94
|
+
},
|
|
49
95
|
},
|
|
50
96
|
checked &&
|
|
51
|
-
h('
|
|
52
|
-
width: 10,
|
|
53
|
-
height: 8,
|
|
54
|
-
onDraw: (ctx) => {
|
|
55
|
-
ctx.strokeStyle = theme.accentText;
|
|
56
|
-
ctx.lineWidth = 2;
|
|
57
|
-
ctx.beginPath();
|
|
58
|
-
ctx.moveTo(1, 4);
|
|
59
|
-
ctx.lineTo(3.5, 6.5);
|
|
60
|
-
ctx.lineTo(9, 1);
|
|
61
|
-
ctx.stroke();
|
|
62
|
-
},
|
|
63
|
-
}),
|
|
97
|
+
h(Icon, { name: 'check', size: MARK, color: theme.accentText }),
|
|
64
98
|
),
|
|
65
99
|
labelContent(children ?? label, {
|
|
66
|
-
color: disabled ? theme.
|
|
100
|
+
color: disabled ? theme.textMuted : theme.text,
|
|
67
101
|
}),
|
|
68
102
|
);
|
|
69
103
|
}
|